@clawhub-harrylabsj-35a31b2850
Track and manage your social energy levels with assessments and personalized recovery plans to help maintain balance and recharge effectively.
# Social Energy Manager(社交电量管理器)
## Overview
帮助用户追踪和管理社交能量消耗,提供电量评估和恢复方案。
## Trigger
- 社交很累
- 精力不够
- 想一个人待着
- 充电
## Output
JSON: {currentEnergyLevel, energyAssessment{score, color, status, action}, recoveryPlan{immediate[], daily[], weekly[]}, tips[]}
FILE:handler.py
"""Social Energy Manager - 社交电量管理器"""
import json
import re
from datetime import datetime
def parse_social_text(text: str) -> dict:
result = {"current_level": None, "situation": None}
if any(k in text for k in ["很累", "精疲力竭", "耗尽", "充电"]): result["current_level"] = "低"
elif any(k in text for k in ["还行", "正常", "平衡"]): result["current_level"] = "中"
elif any(k in text for k in ["兴奋", "满格", "能量足"]): result["current_level"] = "高"
result["situation"] = text[:80]
return result
def assess_energy_level(level: str) -> dict:
levels = {
"低": {"score": 25, "color": "红色", "status": "需要立即充电", "action": "减少社交,增加独处和恢复性活动"},
"中": {"score": 55, "color": "黄色", "status": "基本平衡,可以维持", "action": "选择性社交,优先高质量独处"},
"高": {"score": 85, "color": "绿色", "status": "社交电量充沛", "action": "适合深度社交和建立连接"}
}
return levels.get(level, levels["中"])
def energy_recovery_plan(level: str) -> dict:
plans = {
"低": {
"immediate": ["拒绝非必要社交", "至少2小时独处", "做一件让自己开心的小事"],
"daily": ["设定每日社交上限(如不超过2次)", "社交后安排恢复时间", "优先一对一小聚而非大群体"],
"weekly": ["安排1次'社交戒毒日'(完全独处)", "进行恢复性活动:散步、阅读、冥想"]
},
"中": {
"immediate": ["保持现有节奏", "有意识地选择高质量社交"],
"daily": ["社交前后做5分钟自我检测", "避免'为社交而社交'"],
"weekly": ["复盘本周社交:哪些充电?哪些耗电?"]
},
"高": {
"immediate": ["保持开放心态", "主动发起高质量深度交流"],
"daily": ["把握能量高峰期建立新的连接"],
"weekly": ["设置社交上限,防止过度社交"]
}
}
return plans.get(level, plans["中"])
def handle(text: str) -> dict:
parsed = parse_social_text(text)
assessment = assess_energy_level(parsed["current_level"] or "中")
recovery = energy_recovery_plan(parsed["current_level"] or "中")
return {
"currentEnergyLevel": parsed["current_level"] or "中",
"energyAssessment": assessment,
"recoveryPlan": recovery,
"socialEnergyLog": {
"date": datetime.now().strftime("%Y-%m-%d"),
"logged": True
},
"tips": [
"社交能量是真实存在的资源,不是性格缺陷",
"高质量社交(少数深度连接)> 低质量社交(多数泛泛之交)",
"学会说'不',是对自己社交电量的保护"
]
}
if __name__ == "__main__":
for tc in ["今天social了一下午,感觉被耗尽了", "周末精力充沛想约朋友出来玩"]:
r = handle(tc)
print(f"Input: {tc}\n -> 电量: {r['currentEnergyLevel']} ({r['energyAssessment']['color']}区 | {r['energyAssessment']['status']})\n -> 立即行动: {r['recoveryPlan']['immediate'][0]}\n")
FILE:skill.json
{
"name": "social-energy-manager",
"slug": "social-energy-manager",
"version": "0.1.0",
"description": "auto-generated skeleton",
"author": "Harry",
"tags": ["lifestyle"],
"handler": "handler.py"
}
Assist in recording, organizing, and retrieving personal knowledge with note templates and knowledge management frameworks.
# Personal Knowledge Hub(个人知识枢纽)
## Overview
帮助用户记录、组织和检索个人知识,提供笔记模板和知识整理框架。
## Trigger
- 记录知识
- 整理笔记
- 知识管理
- 复习
## Workflow
1. 判断用户意图(记录/检索/整理)
2. 生成对应格式的笔记模板或检索结果
3. 提供知识整理框架建议
## Output
JSON: {action, template/note, message}
FILE:handler.py
"""Personal Knowledge Hub - 个人知识枢纽"""
import json, re
from datetime import datetime
def parse_knowledge_input(text):
result = {"action": None, "topic": None, "tags": []}
if any(k in text for k in ["读", "书", "文章", "笔记"]): result["action"] = "capture"
elif any(k in text for k in ["找", "搜", "查询", "复习"]): result["action"] = "retrieve"
elif any(k in text for k in ["整理", "结构", "知识体系"]): result["action"] = "organize"
cleaned = text.replace("读", "").replace("书", "").replace("文章", "").replace("笔记", "").replace("关于", "")
result["topic"] = cleaned.strip()[:30]
return result
def capture_note(topic, tags):
lines = ["1. 核心观点(一句话)", "2. 关键论据(3条)", "3. 我的思考/疑问", "4. 行动指引(下一步)"]
prompt = "\u8bf7\u8bb0\u5f55\u5173\u4e8e\u300c" + topic + "\u300d\u7684\u5185\u5bb9\uff1a\n" + "\n".join(lines)
return {
"note_id": "note-" + datetime.now().strftime("%Y%m%d%H%M%S"),
"topic": topic,
"tags": tags or ["\u672a\u5206\u7c7b"],
"created": datetime.now().isoformat(),
"status": "draft",
"prompt": prompt
}
def handle(text):
parsed = parse_knowledge_input(text)
if parsed["action"] == "capture":
note = capture_note(parsed["topic"], parsed.get("tags", []))
return {"action": "capture", "template": note, "message": "\u5df2\u521b\u5efa\u7b14\u8bb0\u6a21\u677f\u300c" + parsed["topic"] + "\u300d\uff0c\u8bf7\u586b\u5199\u5185\u5bb9"}
elif parsed["action"] == "retrieve":
return {"action": "retrieve", "message": "\u6b63\u5728\u68c0\u7d22\u5173\u4e8e\u300c" + parsed["topic"] + "\u300d\u7684\u5185\u5bb9...", "note": None}
else:
return {"action": "organize", "message": "\u6b63\u5728\u5206\u6790\u300c" + parsed["topic"] + "\u300d\u7684\u77e5\u8bc6\u7ed3\u6784...", "structure": None}
if __name__ == "__main__":
for tc in ["\u6211\u60f3\u8bb0\u5f55\u4e00\u4e0b\u4eca\u5929\u8bfb\u5230\u7684\u5b66\u4e60\u65b9\u6cd5", "\u641c\u7d22\u5173\u4e8e\u521b\u9020\u529b\u7684\u7b14\u8bb0"]:
r = handle(tc)
print("Input: " + tc)
print(" Action: " + r["action"] + " | " + r["message"])
print()
FILE:skill.json
{
"name": "personal-knowledge-hub",
"slug": "personal-knowledge-hub",
"version": "0.1.0",
"description": "auto-generated skeleton",
"author": "Harry",
"tags": ["lifestyle"],
"handler": "handler.py"
}
Analyze sleep habits to assess quality, score restfulness, and provide prioritized improvement tips and basic sleep hygiene guidance.
# Sleep Quality Advisor(睡眠质量顾问)
## Overview
解析用户睡眠习惯,提供睡眠质量评估、改善建议和睡前仪式指导。
## Trigger
- 睡眠不好
- 失眠/入睡困难
- 睡眠质量
- 几点睡
## Workflow
1. 从用户描述中提取睡眠时间、质量和症状
2. 评估睡眠质量并打分
3. 给出针对性改善建议(按优先级排序)
4. 提供睡眠卫生基础原则
## Output
JSON: {sleepAssessment{}, recommendations[], sleepHygiene[], medicalAdvice{}}
FILE:clawhub.json
{
"name": "sleep-quality-advisor",
"version": "0.1.0",
"description": "Sleep quality analysis and advice",
"entry": "scripts/handler.py"
}
FILE:handler.py
#!/usr/bin/env python3
import json, sys
def assess(query):
hours=5 if '2点' in query and '7点' in query else 7
score=max(40, min(95, int(hours*10)))
if '手机' in query or '刷' in query: score-=10
if '很累' in query or '睡不着' in query: score-=8
score=max(35, score)
return score, hours
def handle(query):
score,hours=assess(query)
habits=[{'habit':'睡前使用手机','impact':'高','recommendation':'睡前1小时停止使用电子设备','alternative':'阅读纸质书或呼吸练习'}] if ('手机' in query or '刷' in query) else [{'habit':'作息不规律','impact':'中','recommendation':'固定上床与起床时间','alternative':'建立睡前仪式'}]
return {'sleepAssessment':{'qualityScore':score,'durationHours':hours,'overallRating':'较差' if score<60 else '中等' if score<80 else '良好','strengths':['有改善空间'],'weaknesses':['睡眠不足' if hours<7 else '习惯需优化']},'bedtimeHabitAnalysis':habits,'scheduleAdjustment':{'targetBedtime':'23:00','targetWakeup':'07:00','adjustmentPlan':'每3天提前15-30分钟入睡'},'environmentOptimization':{'lighting':'保持黑暗','noise':'必要时使用耳塞/白噪音','temperature':'18-22°C'},'selfHelpRecommendations':['睡前1小时不用手机','下午减少咖啡因','白天适量运动','尝试4-7-8呼吸法'],'medicalAdvice':{'needConsultation':score<45,'suggestion':'如连续2周无改善,建议咨询睡眠专科医生。'},'disclaimer':'本工具仅提供健康生活建议,不替代专业医疗诊断。'}
if __name__=='__main__':
q=' '.join(sys.argv[1:]) or '我最近总是凌晨2点才睡着,早上7点起床,很累'
print(json.dumps(handle(q), ensure_ascii=False, indent=2))
FILE:package.json
{
"name": "sleep-quality-advisor",
"version": "0.1.0",
"private": true
}
FILE:scripts/handler.py
#!/usr/bin/env python3
import json, sys
def assess(query):
hours=5 if '2点' in query and '7点' in query else 7
score=max(40, min(95, int(hours*10)))
if '手机' in query or '刷' in query: score-=10
if '很累' in query or '睡不着' in query: score-=8
score=max(35, score)
return score, hours
def handle(query):
score,hours=assess(query)
habits=[{'habit':'睡前使用手机','impact':'高','recommendation':'睡前1小时停止使用电子设备','alternative':'阅读纸质书或呼吸练习'}] if ('手机' in query or '刷' in query) else [{'habit':'作息不规律','impact':'中','recommendation':'固定上床与起床时间','alternative':'建立睡前仪式'}]
return {'sleepAssessment':{'qualityScore':score,'durationHours':hours,'overallRating':'较差' if score<60 else '中等' if score<80 else '良好','strengths':['有改善空间'],'weaknesses':['睡眠不足' if hours<7 else '习惯需优化']},'bedtimeHabitAnalysis':habits,'scheduleAdjustment':{'targetBedtime':'23:00','targetWakeup':'07:00','adjustmentPlan':'每3天提前15-30分钟入睡'},'environmentOptimization':{'lighting':'保持黑暗','noise':'必要时使用耳塞/白噪音','temperature':'18-22°C'},'selfHelpRecommendations':['睡前1小时不用手机','下午减少咖啡因','白天适量运动','尝试4-7-8呼吸法'],'medicalAdvice':{'needConsultation':score<45,'suggestion':'如连续2周无改善,建议咨询睡眠专科医生。'},'disclaimer':'本工具仅提供健康生活建议,不替代专业医疗诊断。'}
if __name__=='__main__':
q=' '.join(sys.argv[1:]) or '我最近总是凌晨2点才睡着,早上7点起床,很累'
print(json.dumps(handle(q), ensure_ascii=False, indent=2))
FILE:scripts/test-handler.py
#!/usr/bin/env python3
import json, subprocess
out=subprocess.check_output(['python3','scripts/handler.py','我最近总是凌晨2点才睡着,早上7点起床,很累'], text=True)
data=json.loads(out)
assert 'sleepAssessment' in data
assert 'medicalAdvice' in data
print('PASS sleep-quality-advisor')
FILE:skill.json
{
"name": "sleep-quality-advisor",
"slug": "sleep-quality-advisor",
"version": "0.1.0",
"description": "auto-generated skeleton",
"author": "Harry",
"tags": ["lifestyle"],
"handler": "handler.py"
}
Recommend family-friendly trip destinations with budgets, create daily child-focused itineraries, and offer packing lists and safety tips for family vacations.
# Family Trip Planner(家庭旅行规划师)
## Overview
根据家庭成员结构、孩子年龄、旅行预算和偏好,提供目的地推荐、每日行程安排、预算估算和打包清单。适用于亲子旅行规划。
## Trigger
- 带孩子旅行/出行
- 假期去哪儿玩
- 亲子游攻略
- 旅行规划
## Workflow
1. 解析目的地/预算/天数/孩子年龄
2. 推荐3-5个适合目的地(含评分和预估费用)
3. 生成每日行程(含儿童友好活动)
4. 提供打包清单和安全提示
## Output
JSON: {destinationRecommendations[], dailyItinerary[], budgetEstimate, packingList, safetyTips}
FILE:clawhub.json
{
"name": "family-trip-planner",
"version": "0.1.0",
"description": "Family trip planning assistant",
"entry": "scripts/handler.py"
}
FILE:handler.py
"""Family Trip Planner - 家庭旅行规划师"""
import json
import re
from typing import Optional, List, Dict
DESTINATIONS = {
"北京": {"type": "文化", "score": 9.2, "budget_per_day": 800, "attractions": ["故宫", "天安门", "颐和园", "北京动物园"]},
"上海": {"type": "城市", "score": 8.8, "budget_per_day": 900, "attractions": ["外滩", "豫园", "迪士尼"]},
"成都": {"type": "美食/自然", "score": 9.0, "budget_per_day": 600, "attractions": ["大熊猫基地", "宽窄巷子", "青城山"]},
"杭州": {"type": "自然", "score": 8.7, "budget_per_day": 700, "attractions": ["西湖", "灵隐寺", "千岛湖"]},
"西安": {"type": "历史", "score": 8.9, "budget_per_day": 650, "attractions": ["兵马俑", "大雁塔", "城墙"]},
"广州": {"type": "美食/城市", "score": 8.5, "budget_per_day": 700, "attractions": ["广州塔", "长隆", "沙面"]},
"深圳": {"type": "城市", "score": 8.3, "budget_per_day": 800, "attractions": ["世界之窗", "东部华侨城", "深圳湾"]},
"青岛": {"type": "海滨", "score": 8.6, "budget_per_day": 650, "attractions": ["栈桥", "崂山", "金沙滩"]},
"厦门": {"type": "海滨/文艺", "score": 8.7, "budget_per_day": 700, "attractions": ["鼓浪屿", "厦门大学", "曾厝垵"]},
"苏州": {"type": "园林", "score": 8.8, "budget_per_day": 600, "attractions": ["拙政园", "平江路", "虎丘"]},
}
def parse_request(text: str) -> dict:
result = {"destination": None, "days": None, "budget": None, "child_age": None, "family_size": None}
m = re.search(r'(\d+)(?:天|天左右|天以内)', text)
if m: result["days"] = int(m.group(1))
m = re.search(r'(\d+)(?:岁|周岁)', text)
if m: result["child_age"] = int(m.group(1))
m = re.search(r'(\d+)(?:元|元左右|元以内)', text)
if m: result["budget"] = int(m.group(1))
for city in DESTINATIONS:
if city in text: result["destination"] = city
return result
def recommend_destinations(parsed: dict) -> List[dict]:
budget = parsed.get("budget", 8000)
days = parsed.get("days", 4)
child_age = parsed.get("child_age", 8)
results = []
for name, info in DESTINATIONS.items():
est = round(info["budget_per_day"] * days * (1 + 0.3 * (child_age > 10)))
if est <= budget * 1.2:
results.append({"name": name, "type": info["type"], "score": info["score"], "budget_estimate": {"low": round(est*0.85), "high": round(est*1.15)}, "key_attractions": info["attractions"][:3]})
results.sort(key=lambda x: x["score"], reverse=True)
return results[:5]
def generate_itinerary(destination: str, days: int) -> List[dict]:
schedules = {
"北京": ["故宫深度游(3小时,含儿童讲解)", "天安门广场 + 前门大街", "什刹海胡同游"],
"成都": ["大熊猫基地(上午,早起)", "宽窄巷子 + 人民公园", "夜游锦里"],
"杭州": ["西湖苏堤晨跑/骑行", "灵隐寺 + 龙井村品茶", "宋城千古情演出"],
"上海": ["外滩万国建筑", "豫园城隍庙", "迪士尼全天"],
"西安": ["兵马俑(提前预约)", "大雁塔 + 大唐不夜城", "城墙骑行"],
}
default = ["当地主要景点游览", "特色美食品尝", "休闲活动"]
schedule = schedules.get(destination, default)
return [{"day": d+1, "activity": schedule[d % len(schedule)], "tips": ["注意休息节奏", "带好儿童必备物品"]} for d in range(days)]
def handle(text: str) -> dict:
parsed = parse_request(text)
recommendations = recommend_destinations(parsed)
days = parsed.get("days", 4)
destination = parsed.get("destination", recommendations[0]["name"] if recommendations else "成都")
itinerary = generate_itinerary(destination, days)
budget_range = recommendations[0]["budget_estimate"] if recommendations else {"low": 5000, "high": 8000}
return {
"destinationRecommendations": recommendations,
"dailyItinerary": itinerary,
"budgetEstimate": {"low": budget_range["low"], "high": budget_range["high"], "currency": "CNY"},
"packingList": {"衣物": ["T恤", "长裤", "外套"], "洗漱": ["儿童牙刷", "防晒霜"], "药品": ["儿童退烧药", "创可贴"], "证件": ["身份证", "儿童户口本"]},
"safetyTips": ["提前查看天气预报", "儿童必备药品要带够", "热门景点提前预约"]
}
if __name__ == "__main__":
test_cases = [
"五一假期带6岁孩子去北京玩4天,预算8000元左右",
"暑假带孩子去海边,孩子4岁和8岁,5天预算15000元",
]
print("=== Family Trip Planner 自测 ===\n")
for tc in test_cases:
r = handle(tc)
dests = [d["name"] for d in r["destinationRecommendations"]]
print(f"Input: {tc}\n -> 推荐: {dests}\n -> 行程天数: {len(r['dailyItinerary'])}天\n -> 预算: {r['budgetEstimate']}\n")
FILE:models.py
"""家庭旅行规划师 - 数据模型"""
from dataclasses import dataclass, field
from typing import List, Dict, Optional
from enum import Enum
class TravelPreference(str, Enum):
NATURE = "自然"
CITY = "城市"
CULTURE = "文化"
BEACH = "海边"
THEME_PARK = "主题乐园"
class AccommodationLevel(str, Enum):
ECONOMIC = "经济"
COMFORTABLE = "舒适"
LUXURY = "豪华"
class ChildInterest(str, Enum):
ANIMALS = "动物"
PLAYGROUND = "游乐"
MUSEUM = "博物馆"
NATURE = "自然"
@dataclass
class Destination:
name: str
suitability_score: float
key_attractions: List[str]
recommended_days: int
budget_estimate: Dict[str, int]
@dataclass
class DailyItinerary:
day: int
morning: str
afternoon: str
evening: str
tips: List[str]
@dataclass
class BudgetBreakdown:
transportation: int
accommodation: int
food: int
tickets: int
shopping: int
emergency: int
@dataclass
class KidFriendlyAttraction:
name: str
age_suitability: str
duration: str
notes: List[str]
interest_tags: List[str] = field(default_factory=list)
@dataclass
class PackingList:
clothing: List[str]
toiletries: List[str]
medication: List[str]
documents: List[str]
electronics: List[str] = field(default_factory=list)
kid_items: List[str] = field(default_factory=list)
@dataclass
class FamilyTripRequest:
"""用户旅行请求"""
destination: Optional[str] = None
time_range: Optional[str] = None
days: Optional[int] = None
budget: Optional[int] = None
child_ages: List[int] = field(default_factory=list)
preferences: List[str] = field(default_factory=list)
accommodation_level: str = "舒适"
food_preference: str = "普通"
interests: List[str] = field(default_factory=list)
special_needs: List[str] = field(default_factory=list)
@dataclass
class FamilyTripResponse:
"""旅行规划响应"""
destinations: List[Destination] = field(default_factory=list)
daily_itinerary: List[DailyItinerary] = field(default_factory=list)
budget_breakdown: Optional[BudgetBreakdown] = None
kid_friendly_attractions: List[KidFriendlyAttraction] = field(default_factory=list)
packing_list: Optional[PackingList] = None
error: Optional[str] = None
FILE:package.json
{
"name": "family-trip-planner",
"version": "0.1.0",
"private": true
}
FILE:scripts/handler.py
#!/usr/bin/env python3
import json, re, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from models import Destination, DailyItinerary, BudgetBreakdown, KidFriendlyAttraction, PackingList
DESTINATIONS={"北京":{"score":9.2,"days":4,"budget":[7500,9500],"attractions":["故宫","颐和园","北京动物园","天安门"]},"上海":{"score":8.8,"days":4,"budget":[8000,11000],"attractions":["迪士尼","科技馆","外滩","海洋馆"]},"三亚":{"score":8.9,"days":5,"budget":[12000,18000],"attractions":["亚龙湾","海昌梦幻海洋不夜城","蜈支洲岛"]},"成都":{"score":9.0,"days":4,"budget":[6500,9000],"attractions":["熊猫基地","宽窄巷子","都江堰"]}}
def parse_query(query):
city=None
for c in DESTINATIONS:
if c in query: city=c; break
m=re.search(r'(\d+)天', query); days=int(m.group(1)) if m else 4
m=re.search(r'(\d{4,6})', query); budget=int(m.group(1)) if m else 8000
ages=[int(x) for x in re.findall(r'(\d+)岁', query)]
return city,days,budget,ages
def recommend(city,days):
if city and city in DESTINATIONS:
d=DESTINATIONS[city]; return [Destination(city,d['score'],d['attractions'],days or d['days'],{"low":d['budget'][0],"high":d['budget'][1]})]
return [Destination(n,d['score'],d['attractions'],d['days'],{"low":d['budget'][0],"high":d['budget'][1]}) for n,d in DESTINATIONS.items()][:3]
def itinerary(city,days):
acts=DESTINATIONS.get(city, DESTINATIONS['北京'])['attractions']
return [DailyItinerary(i, f'{acts[(i-1)%len(acts)]} 游玩', '午休+轻松活动', '家庭散步或休闲', ['中午留足休息时间','带水和备用衣物']) for i in range(1,days+1)]
def budget_breakdown(total):
return BudgetBreakdown(int(total*0.25),int(total*0.35),int(total*0.2),int(total*0.1),int(total*0.06),int(total*0.04))
def attractions(city):
acts=DESTINATIONS.get(city, DESTINATIONS['北京'])['attractions']
return [KidFriendlyAttraction(a,'3-12岁','2-3小时',['适合亲子','建议错峰'],['综合']) for a in acts]
def packing(days,ages):
return PackingList([f'T恤 {days}件','长裤 2条','外套 1件'],['牙刷','牙膏','防晒霜','驱蚊液'],['退烧药','创可贴','肠胃药'],['身份证','儿童证件'],['手机充电器','充电宝'],['湿巾','水杯'])
def handle(query):
city,days,budget,ages=parse_query(query); recs=recommend(city,days); chosen=city or recs[0].name
return {'destinationRecommendations':[r.__dict__ for r in recs],'dailyItinerary':[x.__dict__ for x in itinerary(chosen,days)],'budgetBreakdown':budget_breakdown(budget).__dict__,'kidFriendlyAttractions':[x.__dict__ for x in attractions(chosen)],'packingList':packing(days,ages).__dict__}
if __name__=='__main__':
q=' '.join(sys.argv[1:]) or '我们五一带6岁孩子去北京玩4天,预算8000'
print(json.dumps(handle(q), ensure_ascii=False, indent=2))
FILE:scripts/test-handler.py
#!/usr/bin/env python3
import json, subprocess
out=subprocess.check_output(['python3','scripts/handler.py','我们五一带6岁孩子去北京玩4天,预算8000'], text=True)
data=json.loads(out)
assert 'destinationRecommendations' in data
assert 'dailyItinerary' in data
print('PASS family-trip-planner')
FILE:skill.json
{
"name": "family-trip-planner",
"slug": "family-trip-planner",
"version": "0.1.0",
"description": "auto-generated skeleton",
"author": "Harry",
"tags": ["lifestyle"],
"handler": "handler.py"
}
Provides step-by-step decluttering plans with sorting rules and resale options for specific spaces like wardrobes, bookshelves, or drawers.
# Declutter Coach(整理教练)
## Overview
帮助用户对特定空间(衣柜、书架、抽屉等)进行整理,提供分步计划、断舍离原则和变现渠道。
## Trigger
- 整理衣柜/房间
- 断舍离
- 东西太多
- 收纳建议
## Workflow
1. 识别整理目标区域
2. 给出断舍离分类原则
3. 提供分步执行计划(含时间估算)
4. 给出闲置变现渠道
## Output
JSON: {targetArea, declutterPlan{steps[], category_rules[], time_estimate}, tips[], donationChannels[]}
FILE:handler.py
"""Declutter Coach - 整理coach"""
import json
import re
CATEGORIES = {
"衣物": ["当季不穿的", "超过1年没穿的", "变形/褪色的"],
"书籍": ["不会再翻的", "已看过且无收藏价值的", "重复的版本"],
"电子产品": ["3年以上未使用的", "已损坏无法修复的", "配件不全的"],
"文件": ["过期的账单/合同", "无效的会员卡", "无保存价值的宣传单"],
"纪念品": ["失去情感连接的", "破损无法修复的", "重复的"],
}
def parse_declutter_input(text: str) -> dict:
area = None
for cat in CATEGORIES:
if cat in text: area = cat
if not area:
for kw in ["衣柜", "衣橱", "衣服"]: area = "衣物"; break
for kw in ["书架", "书", "书籍"]: area = "书籍"; break
for kw in ["抽屉", "桌面", "房间", "家"]: area = "杂物"; break
area = area or "杂物"
return {"area": area, "urgency": "high" if "乱" in text or "忍" in text else "medium"}
def generate_declutter_plan(area: str) -> dict:
plan = {
"area": area,
"steps": [
"拍一张整理前的照片(对比用)",
"把所有物品拿出来堆在一起(可视化物量)",
"分成4堆:保留 / 送人 / 卖掉 / 丢弃",
"保留区按使用者+使用频率重新定位",
"拍一张整理后的照片"
],
"category_rules": CATEGORIES.get(area, ["功能性判断:过去1年用过?未来6个月会用?"]),
"time_estimate": {"min": 30, "max": 120, "unit": "分钟"},
"motivation": "日本整理教主近藤麻理惠:留下让你心动的东西,其余感谢后放手。"
}
return plan
def handle(text: str) -> dict:
parsed = parse_declutter_input(text)
plan = generate_declutter_plan(parsed["area"])
return {
"targetArea": parsed["area"],
"urgency": parsed["urgency"],
"declutterPlan": plan,
"tips": ["先完成一个小角落建立信心", "整理的顺序:先从最简单的地方开始", "卖出闲置可以补贴整理的动力"],
"donationChannels": ["闲鱼(卖)", "转转", "飞蚂蚁(捐)", "朋友圈送熟人"]
}
if __name__ == "__main__":
for tc in ["衣柜太乱了不知道怎么整理", "书太多要断舍离"]:
r = handle(tc)
print(f"Input: {tc}\n -> 目标区域: {r['targetArea']}\n -> 步骤: {r['declutterPlan']['steps'][0]}\n -> 预计: {r['declutterPlan']['time_estimate']}\n")
FILE:skill.json
{
"name": "declutter-coach",
"slug": "declutter-coach",
"version": "0.1.0",
"description": "auto-generated skeleton",
"author": "Harry",
"tags": ["lifestyle"],
"handler": "handler.py"
}
Helps users find local community resources, events, and support groups based on their interests, location, and availability.
# Community Connector
A skill that helps users find and connect with local community resources, events, and support groups based on their interests and needs.
## Description
Community Connector helps users discover local community resources, events, volunteer opportunities, and support groups. It provides personalized recommendations based on user interests, location, and availability.
## Usage
```bash
# Basic usage
community-connector find --interest "gardening" --location "Beijing"
# Find events
community-connector events --date "2026-04-10"
# Get recommendations
community-connector recommend --profile "new_parent"
```
## Input/Output
**Input**: User interests, location, availability, specific needs
**Output**: Curated list of community resources with contact info, event details, and connection guidance
## Examples
```bash
$ community-connector find --interest "yoga" --location "Haidian"
Found 3 community resources:
1. Haidian Community Yoga Group (meets weekly)
2. Beijing Yoga Enthusiasts (online community)
3. Local Wellness Center (free trial classes)
```
## Development
See design document: `/Users/jianghaidong/.openclaw/workspace/shared/projects/community-connector-design.md`
FILE:handler.py
"""
Community Connector handler - 社区互助连接器
将用户描述的社区/位置/场景信息,转化为具体可验证的连接方案。
"""
import json
import re
from typing import Optional
NEED_TYPES = {
"邻里互助": {
"keywords": ["谁有", "借用", "求助", "帮忙", "邻居", "急需", "急找", "急用"],
"channels": ["业主群", "楼栋群", "楼道告示", "物业"],
"urgency": "high"
},
"社区服务": {
"keywords": ["物业", "居委会", "社区", "街道"],
"channels": ["物业电话", "居委会", "街道办"],
"urgency": "medium"
},
"商业服务": {
"keywords": ["推荐", "哪家", "靠谱", "宠物医院", "诊所", "快递", "维修"],
"channels": ["大众点评", "美团", "业主口碑推荐"],
"urgency": "low"
},
"社交连接": {
"keywords": ["拼", "一起", "组队", "找伴", "活动"],
"channels": ["业主群活动版", "社区活动中心", "豆瓣小组"],
"urgency": "low"
}
}
def parse_location(text: str) -> dict:
"""从用户描述中解析位置信息"""
result = {
"communityName": None,
"district": None,
"city": None,
"building": None,
"unit": None,
"rawMatched": []
}
# 匹配小区名(常见后缀)
patterns = [
(r'([\u4e00-\u9fa5]+花园[\u4e00-\u9fa5]*)', 'community'),
(r'([\u4e00-\u9fa5]+小区)', 'community'),
(r'([\u4e00-\u9fa5]+公寓)', 'community'),
(r'([\u4e00-\u9fa5]+苑|[\u4e00-\u9fa5]+园|[\u4e00-\u9fa5]+庭)', 'community'),
(r'([\u4e00-\u9fa5]+路)(?:[\d\-]+号)?', 'road'),
(r'([\u4e00-\u9fa5]+区)', 'district'),
]
for p, kind in patterns:
m = re.search(p, text)
if m:
result["rawMatched"].append((m.group(1), kind))
if kind == "community" and not result["communityName"]:
result["communityName"] = m.group(1)
elif kind == "district":
result["district"] = m.group(1)
# 匹配楼栋
building_match = re.search(r'(\d+)[栋号]', text)
if building_match:
result["building"] = building_match.group(1)
unit_match = re.search(r'(\d+)单元', text)
if unit_match:
result["unit"] = unit_match.group(1)
return result
def classify_need(text: str) -> str:
"""判断需求类型"""
scores = {}
for ntype, info in NEED_TYPES.items():
score = sum(1 for kw in info["keywords"] if kw in text)
scores[ntype] = score
if max(scores.values()) == 0:
return "邻里互助"
return max(scores, key=scores.get)
def extract_topic(text: str) -> str:
"""提取需求主题"""
# 去掉常见模式
cleaned = re.sub(r'(附近|有没有|谁有|想找|想请|请问)', '', text)
# 去掉位置描述
cleaned = re.sub(r'[\u4e00-\u9fa5]+(?:花园|小区|公寓|路|街|道|苑|园|庭|邨|庄|府|邸|郡|湾|城)', '', cleaned)
# 去掉句末标点
cleaned = re.sub(r'[??。.]+$', '', cleaned)
cleaned = cleaned.strip()
if len(cleaned) >= 2:
return cleaned[:30]
return "相关需求"
def generate_opener(text: str, need_type: str, location: dict) -> str:
"""生成联系话术"""
topic = extract_topic(text)
is_urgent = any(kw in text for kw in ["急需", "紧急", "急找", "马上", "立刻", "急用"])
if is_urgent or need_type == "邻里互助":
template = "大家好,我是{unit}的邻居,{topic},请问有没有邻居可以帮忙?谢谢!"
else:
template = "请教各位邻居:{topic},有经验的邻居可以分享一下吗?提前感谢!"
unit_str = ""
if location.get("building"):
unit_str = f"{location['building']}栋"
if location.get("unit"):
unit_str += f"{location['unit']}单元"
opener = template.format(unit=unit_str, topic=topic)
return opener
def generate_connection_plan(need_type: str, location: dict) -> dict:
"""生成连接方案"""
type_info = NEED_TYPES.get(need_type, NEED_TYPES["邻里互助"])
plan = {
"primaryChannel": type_info["channels"][0],
"actionSteps": [],
"estimatedResponseTime": "1-24小时",
"tips": []
}
if need_type == "邻里互助":
if location.get("communityName"):
plan["actionSteps"] = [
f"联系{location['communityName']}物业获取业主群二维码",
"进群后修改群昵称为楼栋单元号",
"在群内发送求助话术"
]
plan["tips"] = ["尽量在白天发送,非紧急避免深夜打扰"]
else:
plan["actionSteps"] = ["确认所在小区名称", "联系物业获取业主群方式"]
elif need_type == "商业服务":
plan["actionSteps"] = [
"在大众点评/美团搜索关键词",
"查看附近评分4.5以上商家",
"在业主群询问真实口碑"
]
plan["estimatedResponseTime"] = "实时"
elif need_type == "社交连接":
plan["actionSteps"] = [
"在业主群活动版发布拼团意向",
"联系社区活动中心了解近期活动",
"加入豆瓣同城相关小组"
]
plan["estimatedResponseTime"] = "1-7天"
else:
plan["actionSteps"] = ["确认具体需求", "联系对应服务机构"]
return plan
def handle(text: str) -> dict:
location = parse_location(text)
need_type = classify_need(text)
opener = generate_opener(text, need_type, location)
connection_plan = generate_connection_plan(need_type, location)
return {
"locationParsed": location,
"needType": need_type,
"urgency": NEED_TYPES[need_type]["urgency"],
"connectionPlan": connection_plan,
"openerScript": opener,
"safetyNotice": (
"请勿在公开场合透露具体门牌号或手机号。"
"涉及医疗急救请拨打120,涉及财产安全隐患请拨打110。"
)
}
if __name__ == "__main__":
test_cases = [
"孩子半夜发烧,附近谁有退烧药?",
"想找小区附近的靠谱宠物医院",
"有没有一起拼周末去植物园的邻居?",
"小区业主群怎么加入?",
]
print("=== Community Connector 自测 ===\n")
for tc in test_cases:
result = handle(tc)
print(f"Input: {tc}")
print(f" -> needType={result['needType']} urgency={result['urgency']}")
print(f" -> opener: {result['openerScript']}")
print(f" -> plan: {result['connectionPlan']['primaryChannel']} | {result['connectionPlan']['actionSteps'][0] if result['connectionPlan']['actionSteps'] else 'N/A'}")
print()
FILE:index.js
#!/usr/bin/env node
const { program } = require('commander');
program
.name('community-connector')
.description('Community resource connection tool')
.version('1.0.0');
program
.command('find')
.description('Find community resources by interest and location')
.option('-i, --interest <type>', 'Interest type (e.g., gardening, yoga, coding)')
.option('-l, --location <area>', 'Location area')
.option('-d, --distance <km>', 'Maximum distance in km', '10')
.action((options) => {
console.log(`Finding community resources for options.interest in options.location...`);
// TODO: Implement actual search logic
console.log('1. Local Gardening Club (meets weekly)');
console.log('2. Community Garden Project (volunteer opportunities)');
console.log('3. Plant Swap Events (monthly)');
});
program
.command('events')
.description('Find upcoming community events')
.option('-d, --date <date>', 'Specific date (YYYY-MM-DD)')
.option('-t, --type <eventType>', 'Event type')
.action((options) => {
console.log('Finding community events...');
// TODO: Implement event search
console.log('1. Community Cleanup - April 15, 2026');
console.log('2. Neighborhood Potluck - April 20, 2026');
console.log('3. Local Market - Every Saturday');
});
program
.command('recommend')
.description('Get personalized recommendations')
.option('-p, --profile <profile>', 'User profile (e.g., new_parent, student, senior)')
.action((options) => {
console.log(`Getting recommendations for options.profile...`);
// TODO: Implement recommendation logic
console.log('1. Parent Support Group (weekly meetings)');
console.log('2. Family Playdates (weekend activities)');
console.log('3. Childcare Resource Center');
});
program.parse();
FILE:package.json
{
"name": "community-connector",
"version": "1.0.0",
"description": "Community resource connection skill",
"main": "index.js",
"scripts": {
"test": "node test.js"
},
"keywords": ["community", "resources", "connection"],
"author": "OpenClaw Team",
"license": "MIT"
}
FILE:skill.json
{
"name": "Community Connector",
"slug": "community-connector",
"version": "0.1.0",
"description": "将用户描述的社区/位置/场景信息,转化为具体可验证的连接方案和话术建议。适用于邻里互助、社区服务、线下社交等场景。",
"author": "Harry",
"tags": ["community", "social", "neighbors"],
"handler": "handler.py"
}
FILE:test.js
// Basic test for community-connector
const { execSync } = require('child_process');
console.log('Testing community-connector...');
try {
// Test help command
const helpOutput = execSync('node index.js --help', { encoding: 'utf8' });
console.log('✓ Help command works');
// Test version
const versionOutput = execSync('node index.js --version', { encoding: 'utf8' });
console.log('✓ Version command works:', versionOutput.trim());
console.log('All basic tests passed!');
} catch (error) {
console.error('Test failed:', error.message);
process.exit(1);
}
Assist users in resolving intimate relationship issues by identifying relationship types, emotions, and offering communication tips and nurturing advice.
# Relationship Nurture Assistant(亲密关系滋养助手)
## Overview
帮助用户处理亲密关系中的沟通问题,提供关系类型判断、实用话术和关系滋养建议。
## Trigger
- 和老婆/老公吵架
- 亲子关系
- 夫妻关系
- 不知道怎么处理关系
- 伴侣生气
## Workflow
1. 判断关系类型(伴侣/亲子/代际)
2. 识别用户情绪和核心问题
3. 给出实用话术和沟通技巧
4. 提供长期滋养建议
## Output
JSON: {relationshipType, detectedEmotion, practicalTips[], examplePhrases[], note}
FILE:handler.py
"""Relationship Nurture Assistant - 亲密关系滋养助手"""
import json, re
def parse_relationship_input(text):
result = {"type": "亲密关系", "party": None, "problem": None, "emotion": None}
if any(k in text for k in ["老婆", "妻子", "老公", "丈夫", "结婚"]): result["type"] = "伴侣"
elif any(k in text for k in ["孩子", "儿子", "女儿", "亲子"]): result["type"] = "亲子"
elif any(k in text for k in ["父母", "爸爸", "妈妈", "婆婆", "岳母"]): result["type"] = "代际"
for e in ["难过", "生气", "失望", "委屈", "困惑", "焦虑"]:
if e in text: result["emotion"] = e
result["problem"] = text[:50]
return result
def get_tips(rtype, emotion):
tips = {
"伴侣": {
"难过": ["先处理情绪,再处理事情", "用'我感到...因为...'代替指责", "约定冷静时间后共同讨论"],
"生气": ["暂停对话,等双方平复", "避免在气头上做决定", "用'我需要...'代替'你总是...'"],
"困惑": ["列出具体困惑点", "选择一个最核心的问题优先讨论", "考虑对方视角"],
},
"亲子": {
"难过": ["先共情:妈妈知道你很难过", "询问需要什么帮助", "陪伴而非立即给解决方案"],
"生气": ["先让情绪过去再沟通", "12岁以下:先连接再纠正", "12岁以上:给予空间再约谈"],
},
"代际": {
"困惑": ["区分关心和控制", "课题分离:谁的事谁做主", "温和但坚定地表达边界"],
}
}
return tips.get(rtype, {}).get(emotion, ["先冷静,再沟通"])
def handle(text):
parsed = parse_relationship_input(text)
tips = get_tips(parsed["type"], parsed.get("emotion", "困惑"))
phrases = {
"伴侣": ["我感到[情绪],因为[原因],我需要[需求]。", "谢谢为这个家付出,想和你聊聊[话题]。"],
"亲子": ["我知道你[情绪],我也曾经[经历]。", "我们一起来想想有什么办法?"],
"代际": ["理解您的担心,我来想想怎么处理。", "这件事我想自己决定,谢谢您的关心。"],
}
return {
"relationshipType": parsed["type"],
"detectedEmotion": parsed.get("emotion"),
"practicalTips": tips,
"examplePhrases": phrases.get(parsed["type"], phrases["伴侣"]),
"note": "关系滋养是长期投资,每天10分钟高质量陪伴大于偶尔一次性大投入。"
}
if __name__ == "__main__":
for tc in ["老婆最近总是加班到很晚,回家都很累,不知道怎么关心她", "青春期的儿子不愿意和我说话了,怎么办"]:
r = handle(tc)
print("Input: " + tc)
print(" Type: " + r["relationshipType"] + " | Emotion: " + str(r["detectedEmotion"]))
print(" Tip: " + r["practicalTips"][0])
print()
FILE:skill.json
{
"name": "relationship-nurture-assistant",
"slug": "relationship-nurture-assistant",
"version": "0.1.0",
"description": "auto-generated skeleton",
"author": "Harry",
"tags": ["lifestyle"],
"handler": "handler.py"
}
Digital Life Organizer / 数字生活整理师. 帮助用户盘点数字资产、整理文件、管理订阅服务、审计密码安全。
---
name: digital-life-organizer
slug: digital-life-organizer
version: 0.1.0
description: |
Digital Life Organizer / 数字生活整理师.
帮助用户盘点数字资产、整理文件、管理订阅服务、审计密码安全。
---
# Digital Life Organizer / 数字生活整理师
你是**数字生活整理师**。
你的任务不是罗列功能清单,而是帮助用户真实地掌控自己的数字生活:
在数字资产爆炸的时代,帮助用户整理数字资产、减少信息焦虑、节省不必要的订阅支出、提升账户安全水平。
## 产品定位
Digital Life Organizer 覆盖四大核心场景:
- **数字资产盘点**:扫描和盘点用户的数字资产,建立完整的数字资产档案
- **文件分类整理**:智能分析文件类型、发现重复文件、生成整理方案
- **订阅服务管理**:全面梳理订阅服务,识别低价值订阅,发现节省机会
- **密码安全审计**:评估密码强度,检查双因素认证覆盖,发现安全风险
## 使用场景
用户可能会说:
- "帮我盘点一下我的数字资产"
- "整理一下我的文件,看看有哪些重复的"
- "分析一下我的订阅服务,有没有不必要的"
- "审计一下我的密码安全"
- "给我做一个全面的数字生活审计"
## 输入 schema
```typescript
interface DigitalLifeRequest {
action: "scan_assets" | "organize_files" | "manage_subscriptions" | "audit_security" | "full_audit";
params?: {
scope?: string[];
deepScan?: boolean;
files?: FileInfo[];
includeMetadata?: boolean;
};
}
```
### action 类型说明
| action | 说明 | 主要输出 |
|--------|------|----------|
| `scan_assets` | 扫描数字资产,建立资产档案 | 资产总览、分类统计、价值评估、清理建议 |
| `organize_files` | 分析文件,给出分类整理方案 | 分类统计、重复文件、过期文件、整理计划 |
| `manage_subscriptions` | 分析订阅服务 | 服务列表、按类别统计、低价值订阅、优化方案 |
| `audit_security` | 密码安全审计 | 安全评分、风险列表、改进计划 |
| `full_audit` | 全面数字生活审计 | 综合资产+订阅+安全三个维度 |
## 输出 schema
```typescript
interface DigitalLifeResponse {
success: boolean;
type: "asset_scan" | "file_organization" | "subscription_management" | "security_audit" | "full_audit";
data: AssetScanData | FileOrganizationData | SubscriptionData | SecurityAuditData | FullAuditData;
error?: string;
}
```
### AssetScanData(资产扫描)
```typescript
interface AssetScanData {
profile: {
id: string;
userId: string;
overview: {
totalAssets: number;
totalSize: number; // bytes
estimatedValue: number;
lastUpdated: string;
};
categories: {
documents: DocumentAssets;
media: MediaAssets;
applications: AppAssets;
subscriptions: SubscriptionAssets;
accounts: AccountAssets;
};
storage: {
local: LocalStorage[];
cloud: CloudStorage[];
};
};
report: {
summary: {
totalAssets: number;
totalSizeGB: number;
estimatedValue: number;
activeSubscriptions: number;
monthlySubscriptionCost: number;
yearlySubscriptionCost: number;
storageUsedGB: number;
storageFreeGB: number;
};
highlights: string[];
recommendations: {
category: string;
priority: "low" | "medium" | "high";
title: string;
description: string;
action: string;
}[];
};
}
```
### FileOrganizationData(文件整理)
```typescript
interface FileOrganizationData {
analysis: {
totalFiles: number;
categorized: Record<string, { count: number; size: number }>;
duplicates: { hash: string; files: string[]; size: number; recommendedAction: string }[];
outdated: { name: string; lastModified: string; ageDays: number }[];
largeFiles: { name: string; size: number; location: string }[];
organizationScore: number;
suggestions: { type: string; from?: string; to?: string; pattern: string; description: string }[];
actionPlan: { step: number; action: string; files: number; estimatedTime: string; impact: string }[];
};
plan: {
id: string;
created: string;
estimatedDuration: string;
estimatedSpaceFreed: string;
beforeScore: number;
afterScore: number;
};
}
```
### SubscriptionData(订阅管理)
```typescript
interface SubscriptionData {
subscriptions: {
id: string;
service: string;
plan: string;
monthlyCost: number;
category: string;
valueScore: number;
usage: { frequency: number; lastUsed: string };
renewal: { nextDate: string; autoRenew: boolean };
}[];
analysis: {
summary: {
totalCount: number;
totalMonthly: number;
totalYearly: number;
averageValueScore: string;
};
byCategory: Record<string, { count: number; cost: number }>;
underused: { service: string; monthlyCost: number; usageFrequency: number; reason: string }[];
highValue: { service: string; monthlyCost: number; valueScore: number }[];
upcomingRenewals: { service: string; nextDate: string; monthlyCost: number }[];
savingsOpportunities: {
type: "cancel" | "downgrade";
service: string;
monthlySaving?: number;
reason: string;
risk: "low" | "medium" | "high";
}[];
};
plan: {
id: string;
potentialMonthlySavings: number;
potentialYearlySavings: number;
actions: { step: number; action: string; service: string; savings: number; reason: string; risk: string }[];
alternativeRecommendations: { current: string; alternatives: string[]; savedPerYear: number }[];
};
}
```
### SecurityAuditData(安全审计)
```typescript
interface SecurityAuditData {
overview: {
overallScore: number;
components: {
passwordStrength: number;
uniqueness: number;
twoFactor: number;
breachExposure: number;
};
passwordStats: {
total: number;
weak: number;
reused: number;
old: number;
compromised: number;
};
accountStats: {
total: number;
with2FA: number;
without2FA: number;
highValue: number;
};
};
report: {
summary: {
overallScore: number;
grade: "A" | "B" | "C" | "D" | "F";
riskLevel: "low" | "medium" | "high";
};
risks: {
type: string;
severity: "low" | "medium" | "high";
affected: number;
description: string;
action: string;
}[];
improvements: {
priority: number;
action: string;
impact: string;
effort: "low" | "medium" | "high";
}[];
};
plan: {
id: string;
currentScore: number;
targetScore: number;
timeline: string;
milestones: { week: string; action: string; targetScore: number }[];
};
}
```
## 核心引擎说明
### 1. AssetDiscoveryEngine(数字资产盘点引擎)
扫描本地设备、云存储账户,汇总数字资产总览。
- `scan(options)`: 执行资产扫描
- `generateReport(profile)`: 生成资产报告
- `generateRecommendations(profile)`: 生成清理建议
### 2. FileOrganizationEngine(文件分类整理引擎)
分析文件类型分布、发现重复文件、生成整理行动方案。
- `analyze(files, options)`: 分析文件
- `generateOrganizationPlan(analysis)`: 生成分类整理计划
### 3. SubscriptionEngine(订阅服务管理引擎)
汇总订阅服务、分析使用价值、识别节省机会。
- `getOverview()`: 获取订阅总览
- `analyzeSubscriptions(subscriptions)`: 分析订阅数据
- `generateOptimizationPlan(analysis)`: 生成优化方案
### 4. PasswordSecurityEngine(密码安全审计引擎)
评估密码安全状况、识别风险账户、制定改进计划。
- `getSecurityOverview()`: 获取安全总览
- `generateAuditReport(overview)`: 生成审计报告
- `generateImprovementPlan(auditReport)`: 生成改进计划
## handler 字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `action` | string | 是 | 操作类型:`scan_assets` / `organize_files` / `manage_subscriptions` / `audit_security` / `full_audit` |
| `params` | object | 否 | 操作参数 |
| `params.scope` | string[] | 否 | 扫描范围,默认 `["local", "cloud"]` |
| `params.deepScan` | boolean | 否 | 是否深度扫描,默认 `false` |
| `params.files` | FileInfo[] | 否 | 文件列表(仅 `organize_files` 时使用) |
## 使用示例
### 示例1:数字资产全面扫描
**输入**:
```json
{
"action": "scan_assets",
"params": { "scope": ["local", "cloud"], "deepScan": true }
}
```
**输出摘要**:
```json
{
"success": true,
"type": "asset_scan",
"data": {
"report": {
"summary": {
"totalAssets": 1247,
"totalSizeGB": 128,
"estimatedValue": 8500,
"activeSubscriptions": 3,
"monthlySubscriptionCost": 26,
"yearlySubscriptionCost": 312
},
"highlights": [
"📁 共发现 1247 个数字资产,总计 128GB",
"💰 数字资产估计价值 ¥8500",
"📱 当前活跃订阅 3 个,月均 ¥26,年约 ¥312"
]
}
}
}
```
### 示例2:订阅服务优化分析
**输入**:
```json
{ "action": "manage_subscriptions", "params": {} }
```
**输出摘要**:
```json
{
"success": true,
"type": "subscription_management",
"data": {
"analysis": {
"summary": { "totalCount": 5, "totalMonthly": 26, "totalYearly": 312 },
"underused": [
{ "service": "爱奇艺", "monthlyCost": 16.5, "usageFrequency": 4, "reason": "使用频率过低" }
],
"savingsOpportunities": [
{ "type": "cancel", "service": "爱奇艺", "monthlySaving": 16.5, "risk": "low" }
]
},
"plan": { "potentialMonthlySavings": 16.5, "potentialYearlySavings": 198 }
}
}
```
### 示例3:密码安全审计
**输入**:
```json
{ "action": "audit_security", "params": {} }
```
**输出摘要**:
```json
{
"success": true,
"type": "security_audit",
"data": {
"overview": { "overallScore": 72 },
"report": {
"summary": { "grade": "C", "riskLevel": "medium" },
"risks": [
{ "type": "weak-password", "severity": "high", "affected": 8, "action": "立即修改为强密码" },
{ "type": "no-2fa", "severity": "medium", "affected": 16, "action": "为邮箱、金融账号启用2FA" }
]
}
}
}
```
## 触发词
- 数字生活整理师
- 数字资产盘点
- 文件整理
- 订阅服务管理
- 密码安全审计
- 全面数字生活审计
- 数字生活审计
## 注意事项
1. **数据范围**:当前为 MVP 阶段,资产扫描为模拟数据,实际使用需接入真实系统接口。
2. **安全优先**:密码审计仅提供建议,不存储或传输任何实际密码。
3. **订阅时效**:订阅数据为模拟示例,实际使用需接入对应平台 API 获取真实订阅状态。
4. **文件分析**:文件整理方案仅供参考,执行前建议备份重要数据。
5. **版本**:`full_audit` 综合 `scan_assets`、`manage_subscriptions`、`audit_security` 三个模块,适合定期全面体检。
FILE:clawhub.json
{
"name": "digital-life-organizer",
"version": "0.1.0",
"description": "数字生活整理师 - 帮助用户整理数字资产、减少信息焦虑的系统,通过数字资产盘点、文件分类整理、订阅服务管理和密码安全审计,帮助用户建立有序的数字生活。",
"keywords": ["digital-life", "organizer", "digital-asset", "file-organization", "subscription", "password-security"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/digital-life-organizer"
}
FILE:index.js
/**
* Digital Life Organizer - 数字生活整理师
*
* 核心功能:
* 1. 数字资产盘点 - 扫描和盘点用户的数字资产
* 2. 文件分类整理 - 智能分类和整理数字文件
* 3. 订阅服务管理 - 管理各种数字订阅服务
* 4. 密码安全审计 - 审计密码安全状况
*/
export const SKILL_NAME = 'digital-life-organizer';
export const VERSION = '0.1.0';
/**
* 数字资产盘点引擎
*/
export class AssetDiscoveryEngine {
constructor() {
this.name = 'AssetDiscoveryEngine';
}
async scan(options = {}) {
const { scope = ['local', 'cloud'], deepScan = false, includeMetadata = true } = options;
return this.generateMockProfile(scope, deepScan, includeMetadata);
}
generateMockProfile(scope, deepScan, includeMetadata) {
return {
id: `profile_Date.now()`,
userId: 'user_default',
overview: {
totalAssets: 1247,
totalSize: 128 * 1024 * 1024 * 1024,
estimatedValue: 8500,
lastUpdated: new Date().toISOString()
},
categories: {
documents: {
count: 423,
size: 2.1 * 1024 * 1024 * 1024,
types: [
{ format: 'pdf', count: 189, size: 890 * 1024 * 1024, importance: 7 },
{ format: 'docx', count: 156, size: 245 * 1024 * 1024, importance: 6 },
{ format: 'xlsx', count: 78, size: 156 * 1024 * 1024, importance: 5 }
],
categories: ['work', 'personal', 'financial', 'legal'],
important: [
{ id: 'doc_1', name: '劳动合同.pdf', type: 'pdf', importance: 'critical', location: '/Documents/Legal/', backup: true, accessFrequency: 2 },
{ id: 'doc_2', name: '房产证.pdf', type: 'pdf', importance: 'critical', location: '/Documents/Property/', backup: true, accessFrequency: 1 }
],
outdated: [{ id: 'doc_3', name: '旧简历_2019.docx', lastModified: '2019-03-15' }]
},
media: {
photos: {
count: 856,
size: 65 * 1024 * 1024 * 1024,
years: [
{ year: 2024, count: 234, size: 18 * 1024 * 1024 * 1024, highlights: ['春节', '暑假旅行'] },
{ year: 2023, count: 312, size: 24 * 1024 * 1024 * 1024, highlights: ['毕业典礼', '婚礼'] },
{ year: 2022, count: 198, size: 15 * 1024 * 1024 * 1024, highlights: ['新房装修'] }
],
locations: [
{ location: '北京', count: 423 },
{ location: '上海', count: 156 },
{ location: '海外', count: 89 }
],
people: [
{ person: '家人', count: 534, years: [2022, 2023, 2024], relationship: 'family' },
{ person: '朋友', count: 312, years: [2023, 2024], relationship: 'friend' }
],
duplicates: [
{ hash: 'abc123', files: ['/Photos/IMG_001.jpg', '/Photos/Backup/IMG_001.jpg'], size: 12 * 1024 * 1024, recommendedAction: 'keep-one' }
]
},
videos: { count: 45, size: 48 * 1024 * 1024 * 1024 },
audio: { count: 28, size: 1.2 * 1024 * 1024 * 1024 },
other: []
},
applications: {
installed: [
{ name: '微信', version: '8.0.48', size: 890 * 1024 * 1024, lastUsed: '2024-01-15', usageFrequency: 50, importance: 10 },
{ name: '钉钉', version: '7.0.30', size: 456 * 1024 * 1024, lastUsed: '2024-01-15', usageFrequency: 30, importance: 8 },
{ name: 'Xcode', version: '15.2', size: 45 * 1024 * 1024 * 1024, lastUsed: '2023-12-20', usageFrequency: 5, importance: 6 }
],
unused: [
{ app: { name: 'Photoshop', version: '2023', size: 4.5 * 1024 * 1024 * 1024, lastUsed: '2023-06-15', usageFrequency: 1, importance: 4 }, daysSinceLastUse: 180, potentialSavings: 380, removalRisk: 'medium' }
],
outdated: [],
licenses: []
},
data: {
databases: [],
backups: [{ name: 'TimeMachine', lastBackup: '2024-01-14', size: 180 * 1024 * 1024 * 1024, status: 'healthy' }],
archives: [],
exports: []
},
accounts: {
count: 47,
categories: [
{ type: 'email', count: 5, important: [{ service: 'Gmail', username: '[email protected]', recoveryEmail: '[email protected]', twoFactor: true, lastLogin: '2024-01-15' }], inactive: [] },
{ type: 'social', count: 12, important: [{ service: '微信', username: 'user_wechat', recoveryEmail: '[email protected]', twoFactor: true, lastLogin: '2024-01-15' }], inactive: [] },
{ type: 'financial', count: 8, important: [{ service: '支付宝', username: '[email protected]', recoveryEmail: '[email protected]', twoFactor: true, lastLogin: '2024-01-14' }], inactive: [] }
],
security: { passwordStrength: 72, twoFactorAdoption: 65, uniquePasswords: 38, compromised: [] }
},
subscriptions: {
active: [
{ service: 'iCloud+', plan: '200GB', monthlyCost: 6, valueScore: 8, usage: { frequency: 30, duration: 5, features: [{ name: '照片备份', usage: 100 }, { name: '文档同步', usage: 60 }], satisfaction: 9 }, renewal: { nextDate: '2024-02-15', autoRenew: true, cancellationPolicy: '随时取消', priceChange: null } },
{ service: 'ChatGPT Plus', plan: '月度', monthlyCost: 20, valueScore: 9, usage: { frequency: 60, duration: 15, features: [{ name: 'GPT-4', usage: 100 }, { name: '插件', usage: 40 }], satisfaction: 9 }, renewal: { nextDate: '2024-02-10', autoRenew: true, cancellationPolicy: '提前24小时', priceChange: null } },
{ service: '京东Plus', plan: '年度', monthlyCost: 99, valueScore: 7, usage: { frequency: 15, duration: 10, features: [{ name: '免运费', usage: 100 }, { name: '专属优惠', usage: 70 }], satisfaction: 7 }, renewal: { nextDate: '2024-08-01', autoRenew: true, cancellationPolicy: '提前30天', priceChange: null } }
],
inactive: [],
trials: [],
duplicates: []
}
},
storage: {
local: {
devices: [{ name: 'MacBook Pro', type: 'internal', capacity: 512 * 1024 * 1024 * 1024, used: 378 * 1024 * 1024 * 1024, free: 134 * 1024 * 1024 * 1024, health: { status: 'healthy', issues: [], recommendations: ['建议清理旧备份'] } }],
organization: { score: 68 },
efficiency: { fragmentation: 12, waste: 23 }
},
cloud: [{ provider: 'iCloud', plan: '200GB', used: 156 * 1024 * 1024 * 1024, total: 200 * 1024 * 1024 * 1024, cost: 6, sync: { enabled: true, frequency: 'realtime', conflicts: 0, lastSync: '2024-01-15T22:00:00Z' } }],
hybrid: []
},
access: { frequency: { daily: 12, weekly: 45, monthly: 89, seasonal: [] }, patterns: [], bottlenecks: [] },
lifecycle: {
creation: { totalCreated: 1247, byYear: { 2024: 423, 2023: 534, 2022: 290 } },
modification: { totalModified: 567, byYear: { 2024: 123, 2023: 234, 2022: 210 } },
access: { totalAccessed: 8923 },
archival: { archived: 234, totalSize: 12 * 1024 * 1024 * 1024 }
}
};
}
generateReport(profile) {
const { overview, categories, storage } = profile;
const totalMonthly = categories.subscriptions.active.reduce((sum, s) => sum + s.monthlyCost, 0);
return {
summary: {
totalAssets: overview.totalAssets,
totalSizeGB: Math.round(overview.totalSize / (1024 ** 3)),
estimatedValue: overview.estimatedValue,
activeSubscriptions: categories.subscriptions.active.length,
monthlySubscriptionCost: totalMonthly,
yearlySubscriptionCost: totalMonthly * 12,
storageUsedGB: Math.round((storage.local.devices[0]?.used || 0) / (1024 ** 3)),
storageFreeGB: Math.round((storage.local.devices[0]?.free || 0) / (1024 ** 3))
},
highlights: [
`📁 共发现 overview.totalAssets 个数字资产,总计 Math.round(overview.totalSize / (1024 ** 3))GB`,
`💰 数字资产估计价值 ¥overview.estimatedValue`,
`📱 当前活跃订阅 categories.subscriptions.active.length 个,月均 ¥totalMonthly.toFixed(0),年约 ¥(totalMonthly * 12).toFixed(0)`,
`💾 本地存储使用 Math.round((storage.local.devices[0]?.used || 0) / (1024 ** 3))GB,剩余 Math.round((storage.local.devices[0]?.free || 0) / (1024 ** 3))GB`,
`🔐 密码安全评分 categories.accounts.security.passwordStrength/100`
].filter(Boolean),
recommendations: this.generateRecommendations(profile)
};
}
generateRecommendations(profile) {
const recs = [];
const { categories, storage } = profile;
const lowValueSubs = categories.subscriptions.active.filter(s => s.valueScore < 5);
if (lowValueSubs.length > 0) {
recs.push({ category: 'subscription', priority: 'medium', title: '审视低价值订阅', description: `lowValueSubs.map(s => s.service).join('、') 价值评分较低,可考虑取消`, action: 'review' });
}
const freePercent = storage.local.devices[0]?.free / storage.local.devices[0]?.capacity;
if (freePercent < 0.2) {
recs.push({ category: 'storage', priority: 'high', title: '存储空间不足', description: `本地存储剩余仅 Math.round(freePercent * 100)%,建议清理或扩容`, action: 'cleanup' });
}
if (categories.accounts.security.passwordStrength < 70) {
recs.push({ category: 'security', priority: 'high', title: '提升密码强度', description: '密码安全评分偏低,建议使用密码管理器并启用双因素认证', action: 'security_audit' });
}
return recs;
}
}
/**
* 文件分类整理引擎
*/
export class FileOrganizationEngine {
constructor() {
this.name = 'FileOrganizationEngine';
this.defaultTaxonomy = this.buildDefaultTaxonomy();
}
buildDefaultTaxonomy() {
return {
categories: [
{ id: 'work', name: '工作', icon: '💼', color: '#4285F4', subcategories: ['项目文档', '会议记录', '报告', '合同'] },
{ id: 'personal', name: '个人', icon: '🏠', color: '#34A853', subcategories: ['日记', '照片', '视频', '收藏'] },
{ id: 'financial', name: '财务', icon: '💰', color: '#FBBC05', subcategories: ['账单', '发票', '银行对账单', '投资'] },
{ id: 'legal', name: '法律', icon: '⚖️', color: '#EA4335', subcategories: ['合同', '证书', '协议'] },
{ id: 'media', name: '媒体', icon: '🎬', color: '#9C27B0', subcategories: ['照片', '视频', '音乐'] },
{ id: 'archive', name: '归档', icon: '📦', color: '#607D8B', subcategories: ['旧文件', '备份'] }
],
tags: [
{ name: '重要', color: '#EA4335', usage: 156 },
{ name: '待处理', color: '#FBBC05', usage: 89 },
{ name: '参考', color: '#4285F4', usage: 234 },
{ name: '敏感', color: '#9C27B0', usage: 45 }
]
};
}
async analyze(files, options = {}) {
return {
totalFiles: files?.length || 247,
categorized: {
work: { count: 89, size: 1.2 * 1024 * 1024 * 1024 },
personal: { count: 67, size: 45 * 1024 * 1024 * 1024 },
financial: { count: 34, size: 234 * 1024 * 1024 },
legal: { count: 12, size: 45 * 1024 * 1024 },
media: { count: 123, size: 65 * 1024 * 1024 * 1024 },
archive: { count: 23, size: 12 * 1024 * 1024 * 1024 }
},
duplicates: [
{ hash: 'abc123', files: ['photo1.jpg', 'photo1_backup.jpg'], size: 24 * 1024 * 1024, recommendedAction: 'keep-one' }
],
outdated: [
{ name: '旧简历_2019.docx', lastModified: '2019-03-15', ageDays: 1732 },
{ name: '项目资料_2020.zip', lastModified: '2020-12-01', ageDays: 1106 }
],
largeFiles: [
{ name: '视频素材_毕业典礼.mp4', size: 4.5 * 1024 * 1024 * 1024, location: '/Videos/' },
{ name: '虚拟机镜像.vmware', size: 80 * 1024 * 1024 * 1024, location: '/Apps/' }
],
organizationScore: 68,
suggestions: [
{ type: 'move', from: '/Downloads/', to: '/Documents/', pattern: '*.pdf', description: '将下载的PDF移至文档目录' },
{ type: 'archive', pattern: '*_old_*', description: '归档超过2年的旧文件' }
],
actionPlan: [
{ step: 1, action: '清理重复文件', files: 2, estimatedTime: '5分钟', impact: '释放 29MB' },
{ step: 2, action: '归档旧文件', files: 15, estimatedTime: '10分钟', impact: '释放 8GB' },
{ step: 3, action: '整理下载目录', files: 45, estimatedTime: '20分钟', impact: '提升整理度' },
{ step: 4, action: '迁移大文件到外部存储', files: 3, estimatedTime: '30分钟', impact: '释放 85GB' }
]
};
}
generateOrganizationPlan(analysis) {
return {
id: `plan_Date.now()`,
created: new Date().toISOString(),
estimatedDuration: '65分钟',
estimatedSpaceFreed: '93GB',
steps: analysis.actionPlan,
beforeScore: analysis.organizationScore,
afterScore: Math.min(95, analysis.organizationScore + 20)
};
}
}
/**
* 订阅服务管理引擎
*/
export class SubscriptionEngine {
constructor() { this.name = 'SubscriptionEngine'; }
getOverview() {
return [
{ id: 'sub_1', service: 'iCloud+', plan: '200GB', monthlyCost: 6, category: 'cloud', valueScore: 8, usage: { frequency: 30, lastUsed: '2024-01-15' }, renewal: { nextDate: '2024-02-15', autoRenew: true } },
{ id: 'sub_2', service: 'ChatGPT Plus', plan: '月度', monthlyCost: 20, category: 'ai', valueScore: 9, usage: { frequency: 60, lastUsed: '2024-01-15' }, renewal: { nextDate: '2024-02-10', autoRenew: true } },
{ id: 'sub_3', service: '京东Plus', plan: '年度', monthlyCost: 99, category: 'shopping', valueScore: 7, usage: { frequency: 15, lastUsed: '2024-01-10' }, renewal: { nextDate: '2024-08-01', autoRenew: true } },
{ id: 'sub_4', service: '爱奇艺', plan: '年度', monthlyCost: 198, category: 'entertainment', valueScore: 5, usage: { frequency: 4, lastUsed: '2023-12-20' }, renewal: { nextDate: '2024-06-01', autoRenew: true } },
{ id: 'sub_5', service: 'Spotify', plan: '个人', monthlyCost: 15, category: 'music', valueScore: 8, usage: { frequency: 120, lastUsed: '2024-01-15' }, renewal: { nextDate: '2024-02-05', autoRenew: true } }
];
}
analyzeSubscriptions(subscriptions) {
const totalMonthly = subscriptions.reduce((sum, s) => sum + s.monthlyCost, 0);
const byCategory = {};
subscriptions.forEach(s => {
if (!byCategory[s.category]) byCategory[s.category] = { count: 0, cost: 0 };
byCategory[s.category].count++;
byCategory[s.category].cost += s.monthlyCost;
});
return {
summary: { totalCount: subscriptions.length, totalMonthly, totalYearly: totalMonthly * 12, averageValueScore: (subscriptions.reduce((sum, s) => sum + s.valueScore, 0) / subscriptions.length).toFixed(1) },
byCategory,
underused: subscriptions.filter(s => s.usage.frequency < 5).map(s => ({ service: s.service, monthlyCost: s.monthlyCost, usageFrequency: s.usage.frequency, reason: '使用频率过低' })),
highValue: subscriptions.filter(s => s.valueScore >= 8),
upcomingRenewals: subscriptions.filter(s => { const daysUntil = Math.ceil((new Date(s.renewal.nextDate) - new Date()) / (1000 * 60 * 60 * 24)); return daysUntil <= 30 && daysUntil > 0; }),
savingsOpportunities: [
{ type: 'cancel', service: '爱奇艺', monthlySaving: 16.5, reason: '连续3个月使用频率低于5次', risk: 'low' },
{ type: 'downgrade', service: 'iCloud+', currentPlan: '200GB', targetPlan: '50GB', monthlySaving: 3, risk: 'low' }
]
};
}
generateOptimizationPlan(analysis) {
const potentialSavings = analysis.savingsOpportunities.reduce((sum, o) => sum + (o.monthlySaving || 0), 0);
return {
id: `opt_Date.now()`,
created: new Date().toISOString(),
potentialMonthlySavings: potentialSavings,
potentialYearlySavings: potentialSavings * 12,
actions: analysis.savingsOpportunities.map((o, i) => ({ step: i + 1, action: o.type === 'cancel' ? '取消订阅' : '降级方案', service: o.service, savings: o.monthlySaving, reason: o.reason, risk: o.risk })),
alternativeRecommendations: [{ current: '爱奇艺', alternatives: ['B站大会员(¥233/年,含弹幕)', '腾讯视频(¥263/年)'], savedPerYear: 66 }]
};
}
}
/**
* 密码安全审计引擎
*/
export class PasswordSecurityEngine {
constructor() { this.name = 'PasswordSecurityEngine'; }
getSecurityOverview() {
return {
overallScore: 72,
components: { passwordStrength: 68, uniqueness: 85, twoFactor: 65, breachExposure: 95 },
passwordStats: { total: 47, weak: 8, reused: 12, old: 15, compromised: 0 },
accountStats: { total: 47, with2FA: 31, without2FA: 16, highValue: 12 }
};
}
generateAuditReport(overview) {
const risks = [];
if (overview.passwordStats.weak > 5) risks.push({ type: 'weak-password', severity: 'high', affected: overview.passwordStats.weak, description: `overview.passwordStats.weak 个账户使用弱密码`, action: '立即修改为强密码' });
if (overview.passwordStats.reused > 0) risks.push({ type: 'password-reuse', severity: 'medium', affected: overview.passwordStats.reused, description: `overview.passwordStats.reused 个账户重复使用相同密码`, action: '为每个账户设置唯一密码' });
if (overview.accountStats.without2FA > 10) risks.push({ type: 'no-2fa', severity: 'medium', affected: overview.accountStats.without2FA, description: `overview.accountStats.without2FA 个重要账户未启用双因素认证`, action: '为邮箱、金融、社交账号启用2FA' });
return {
summary: { overallScore: overview.overallScore, grade: overview.overallScore >= 90 ? 'A' : overview.overallScore >= 80 ? 'B' : overview.overallScore >= 70 ? 'C' : 'D', riskLevel: overview.overallScore >= 80 ? 'low' : overview.overallScore >= 60 ? 'medium' : 'high' },
risks,
improvements: [
{ priority: 1, action: '启用密码管理器(如1Password、Bitwarden)', impact: '+15分', effort: 'low' },
{ priority: 2, action: '为所有重要账户启用双因素认证', impact: '+10分', effort: 'medium' },
{ priority: 3, action: '修改所有弱密码和重复密码', impact: '+20分', effort: 'high' },
{ priority: 4, action: '检查 haveibeenpwned.com 确认账户泄露情况', impact: '安全确认', effort: 'low' }
]
};
}
generateImprovementPlan(auditReport) {
return {
id: `imp_Date.now()`,
created: new Date().toISOString(),
currentScore: auditReport.summary.overallScore,
targetScore: 90,
timeline: '3个月',
milestones: [
{ week: 1, action: '注册并配置密码管理器', targetScore: 80 },
{ week: '2-4', action: '分批修改高风险账户密码', targetScore: 85 },
{ week: '5-8', action: '为重要账户启用双因素认证', targetScore: 88 },
{ week: '9-12', action: '全面审查并修改剩余弱密码', targetScore: 90 }
]
};
}
}
/**
* 主处理函数
*/
export async function handler(input) {
const { action, params = {} } = input;
try {
switch (action) {
case 'scan_assets': {
const assetEngine = new AssetDiscoveryEngine();
const profile = await assetEngine.scan(params);
const assetReport = assetEngine.generateReport(profile);
return { success: true, type: 'asset_scan', data: { profile, report: assetReport } };
}
case 'organize_files': {
const fileEngine = new FileOrganizationEngine();
const analysis = await fileEngine.analyze(params.files, params);
const plan = fileEngine.generateOrganizationPlan(analysis);
return { success: true, type: 'file_organization', data: { analysis, plan } };
}
case 'manage_subscriptions': {
const subEngine = new SubscriptionEngine();
const subscriptions = subEngine.getOverview();
const subAnalysis = subEngine.analyzeSubscriptions(subscriptions);
const optPlan = subEngine.generateOptimizationPlan(subAnalysis);
return { success: true, type: 'subscription_management', data: { subscriptions, analysis: subAnalysis, plan: optPlan } };
}
case 'audit_security': {
const pwdEngine = new PasswordSecurityEngine();
const securityOverview = pwdEngine.getSecurityOverview();
const auditReport = pwdEngine.generateAuditReport(securityOverview);
const impPlan = pwdEngine.generateImprovementPlan(auditReport);
return { success: true, type: 'security_audit', data: { overview: securityOverview, report: auditReport, plan: impPlan } };
}
case 'full_audit': {
const assetEngine2 = new AssetDiscoveryEngine();
const profile2 = await assetEngine2.scan({});
const assetReport2 = assetEngine2.generateReport(profile2);
const subEngine2 = new SubscriptionEngine();
const subscriptions2 = subEngine2.getOverview();
const subAnalysis2 = subEngine2.analyzeSubscriptions(subscriptions2);
const optPlan2 = subEngine2.generateOptimizationPlan(subAnalysis2);
const pwdEngine2 = new PasswordSecurityEngine();
const securityOverview2 = pwdEngine2.getSecurityOverview();
const auditReport2 = pwdEngine2.generateAuditReport(securityOverview2);
return { success: true, type: 'full_audit', data: { assets: { profile: profile2, report: assetReport2 }, subscriptions: { analysis: subAnalysis2, plan: optPlan2 }, security: { overview: securityOverview2, report: auditReport2 } } };
}
default:
return { success: false, error: 'Unknown action', hint: 'Supported actions: scan_assets, organize_files, manage_subscriptions, audit_security, full_audit' };
}
} catch (error) {
return { success: false, error: error.message };
}
}
export default handler;
FILE:package.json
{
"name": "digital-life-organizer",
"version": "0.1.0",
"description": "数字生活整理师 - 帮你整理数字资产、减少信息焦虑",
"type": "module",
"main": "index.js",
"scripts": {
"test": "node scripts/test-stub.js"
}
}
FILE:scripts/test-stub.js
/**
* Digital Life Organizer - 自测脚本
*/
import { handler } from '../index.js';
async function runTests() {
console.log('开始测试...');
const tests = [
{ name: 'scan_assets', expected: true },
{ name: 'organize_files', expected: true },
{ name: 'manage_subscriptions', expected: true },
{ name: 'audit_security', expected: true },
{ name: 'full_audit', expected: true },
{ name: 'unknown', expected: false }
];
for (const t of tests) {
const r = await handler({ action: t.name, params: {} });
console.log((r.success === t.expected ? '✅' : '❌') + ' ' + t.name);
}
console.log('测试完成');
}
runTests().catch(console.error);
家庭营养规划师 - 根据家庭成员健康需求、饮食偏好和营养目标, 生成营养均衡的一周菜单、食材采购清单和分餐计划。
---
name: family-nutrition-planner
slug: family-nutrition-planner
version: 0.1.0
description: |
家庭营养规划师 - 根据家庭成员健康需求、饮食偏好和营养目标,
生成营养均衡的一周菜单、食材采购清单和分餐计划。
---
# Family Nutrition Planner / 家庭营养规划师
你是**家庭营养规划师**。
你的任务是根据家庭成员的健康需求、饮食偏好和营养目标,生成营养均衡的一周菜单、食材采购清单和分餐计划。
## 产品定位
Family Nutrition Planner 是一个智能家庭饮食规划系统,覆盖:
- **营养需求计算**:基于年龄、性别、体重、身高、活动水平计算每日营养需求
- **一周菜单生成**:生成营养均衡的一周饮食计划(三餐+加餐)
- **食材采购清单**:根据菜单生成优化的分类采购清单和成本估算
- **过敏原管理**:管理食物过敏和禁忌,自动筛选安全食谱
## 使用场景
用户可能会说:
- "为家庭制定一周营养计划"
- "计算一下我家人的营养需求"
- "生成这周的食材采购清单"
- "帮我规划健康饮食"
## 输入 schema(统一需求格式)
```typescript
interface NutritionPlanRequest {
familyName?: string; // 家庭名称(可选)
members: FamilyMember[];
preferences?: {
cuisine?: string[];
cookingStyle?: string[];
avoid?: string[];
};
constraints?: {
weeklyBudget?: number;
maxPrepTime?: number;
servingSize?: number;
};
goals?: {
type?: "balance" | "low-carb" | "high-protein" | "weight-loss" | "muscle-gain";
focus?: string[];
};
}
interface FamilyMember {
name: string;
age: number;
gender: "male" | "female";
weight: number;
height: number;
activityLevel: "sedentary" | "lightly-active" | "moderately-active" | "very-active" | "extra-active";
goals?: string[];
allergies?: string[];
}
```
## 输出 schema(统一营养规划报告)
```typescript
interface NutritionPlanReport {
success: boolean;
nutritionSummary: {
averageDailyCalories: number;
macronutrientBalance: { protein: string; carbohydrates: string; fat: string; };
foodVariety: number;
};
dailyPlans: DailyPlan[];
shoppingList: {
categories: CategoryItem[];
estimatedCost: number;
savingsTips: string[];
};
nutritionAnalysis: {
strengths: string[];
concerns: string[];
suggestions: string[];
};
weeklyNutritionTrend: string;
}
```
## 核心功能
### 1. 营养需求计算
基于 Mifflin-St Jeor 方程:
- 男: BMR = 10×体重(kg) + 6.25×身高(cm) - 5×年龄 + 5
- 女: BMR = 10×体重(kg) + 6.25×身高(cm) - 5×年龄 - 161
- TDEE = BMR × 活动系数
### 2. 菜单生成规则
- 确保每日至少使用 12 种不同食材
- 每餐包含:主食 + 蛋白质 + 蔬菜 + 适量健康脂肪
- 每周食材重复率 < 30%
## 使用示例
### 示例1:一周营养计划
**输入**:
```
家庭成员:
- 爸爸:35岁,男,75kg,175cm,轻度活动
- 妈妈:32岁,女,58kg,162cm,轻度活动
- 孩子:8岁,男,28kg,130cm,中度活动
偏好:中式家常菜,少油少盐
预算:每周500元
```
**输出**:
```
成功生成一周营养计划!
📊 营养概览:人均每日热量1850 kcal,宏量营养素均衡
📅 周一计划:早餐420 kcal,午餐580 kcal,晚餐620 kcal
🛒 采购清单:预估成本约480元
```
### 示例2:营养需求计算
**输入**:30岁男性,70kg,175cm,中度活动,增肌目标
**输出**:
```
📋 营养需求报告
BMR:1665 kcal
TDEE:2581 kcal
目标摄入量:2800 kcal(增肌+8.5%)
蛋白质:210g | 碳水:280g | 脂肪:93g
```
## 当前状态
- **营养计算**:stub(基于标准公式的估算)
- **菜单生成**:stub(返回预设模板菜单)
- **采购清单**:stub(基于菜单的成本估算)
## 自测
```bash
cd ~/.openclaw/skills/family-nutrition-planner
python scripts/test-handler.py
```
FILE:clawhub.json
{
"name": "family-nutrition-planner",
"version": "0.1.0",
"description": "家庭营养规划师 - 生成一周营养计划和采购清单",
"author": "Harry",
"tags": [
"nutrition",
"meal-planning",
"family",
"health"
]
}
FILE:data/nutrition-db.json
{
"description": "Nutrition database for Family Nutrition Planner",
"version": "0.1.0",
"activity_multipliers": {
"sedentary": 1.2,
"lightly-active": 1.375,
"moderately-active": 1.55,
"very-active": 1.725,
"extra-active": 1.9
},
"goal_adjustments": {
"balance": 0,
"low-carb": -0.1,
"high-protein": 0.05,
"weight-loss": -0.2,
"muscle-gain": 0.15
},
"macro_ratios": {
"balance": {"protein": 0.20, "carbs": 0.50, "fat": 0.30},
"low-carb": {"protein": 0.25, "carbs": 0.30, "fat": 0.45},
"high-protein": {"protein": 0.30, "carbs": 0.40, "fat": 0.30},
"weight-loss": {"protein": 0.30, "carbs": 0.40, "fat": 0.30},
"muscle-gain": {"protein": 0.30, "carbs": 0.45, "fat": 0.25}
},
"common_allergens": ["花生", "虾", "牛奶", "鸡蛋", "小麦", "大豆", "坚果"],
"food_categories": {
"蛋白质": ["鸡胸肉", "鸡腿", "牛肉", "猪肉", "排骨", "鲈鱼", "虾仁", "虾", "鸡蛋", "牛奶", "豆腐", "黄豆"],
"蔬菜": ["青菜", "西兰花", "黄瓜", "西红柿", "胡萝卜", "莲藕", "冬瓜", "洋葱", "姜", "蒜", "葱", "白菜"],
"主食": ["大米", "面条", "燕麦", "全麦吐司", "藜麦", "馒头", "土豆"],
"水果": ["香蕉", "蓝莓", "苹果", "橙子", "牛油果"],
"乳制品": ["牛奶", "酸奶", "奶酪"],
"调料": ["酱油", "醋", "橄榄油", "盐", "糖"]
},
"rda_参考值": {
"蛋白质": {"成人": 60, "儿童": 45, "孕妇": 75, "unit": "g"},
"铁": {"成人男": 12, "成人女": 20, "unit": "mg"},
"钙": {"成人": 800, "孕妇": 1000, "unit": "mg"},
"维生素C": {"成人": 100, "unit": "mg"},
"膳食纤维": {"成人": 25, "unit": "g"}
}
}
FILE:data/recipes.json
{
"description": "Recipe database for Family Nutrition Planner",
"version": "0.1.0",
"recipes": {
"breakfast": [
{"name": "全麦吐司配鸡蛋牛油果", "calories": 420, "time": "15分钟", "ingredients": ["全麦吐司", "鸡蛋", "牛油果", "西红柿"], "protein": 18, "tags": ["西式", "高蛋白"]},
{"name": "燕麦粥配水果", "calories": 350, "time": "10分钟", "ingredients": ["燕麦", "牛奶", "香蕉", "蓝莓"], "protein": 12, "tags": ["健康", "快手"]},
{"name": "中式豆腐脑", "calories": 280, "time": "20分钟", "ingredients": ["黄豆", "内酯豆腐", "葱花", "酱油"], "protein": 22, "tags": ["中式", "素食"]},
{"name": "蔬菜鸡蛋饼", "calories": 380, "time": "15分钟", "ingredients": ["鸡蛋", "面粉", "青菜", "胡萝卜"], "protein": 16, "tags": ["中式", "快手"]},
{"name": "牛奶鸡蛋羹", "calories": 320, "time": "15分钟", "ingredients": ["鸡蛋", "牛奶", "葱花"], "protein": 20, "tags": ["中式", "宝宝辅食"]},
{"name": "玉米糊配葡萄干", "calories": 300, "time": "10分钟", "ingredients": ["玉米面", "葡萄干", "糖"], "protein": 8, "tags": ["甜品", "快手"]},
{"name": "豆浆配油条", "calories": 450, "time": "10分钟", "ingredients": ["黄豆", "油条"], "protein": 18, "tags": ["中式", "传统"]},
{"name": "水果沙拉配酸奶", "calories": 280, "time": "10分钟", "ingredients": ["苹果", "香蕉", "酸奶", "蜂蜜"], "protein": 10, "tags": ["健康", "素食"]}
],
"lunch": [
{"name": "清蒸鲈鱼 + 米饭 + 青菜", "calories": 580, "time": "25分钟", "ingredients": ["鲈鱼", "大米", "青菜", "姜"], "protein": 35, "tags": ["中式", "清淡", "高蛋白"]},
{"name": "鸡胸肉沙拉配藜麦", "calories": 520, "time": "20分钟", "ingredients": ["鸡胸肉", "混合蔬菜", "藜麦", "橄榄油"], "protein": 40, "tags": ["健身", "高蛋白"]},
{"name": "番茄牛腩面", "calories": 620, "time": "35分钟", "ingredients": ["牛肉", "西红柿", "面条", "洋葱"], "protein": 32, "tags": ["中式", "主食"]},
{"name": "红烧肉 + 米饭", "calories": 720, "time": "40分钟", "ingredients": ["五花肉", "大米", "土豆", "酱油"], "protein": 28, "tags": ["中式", "硬菜"]},
{"name": "虾仁炒饭", "calories": 550, "time": "20分钟", "ingredients": ["虾仁", "米饭", "鸡蛋", "豌豆"], "protein": 30, "tags": ["快手", "海鲜"]},
{"name": "宫保鸡丁 + 米饭", "calories": 600, "time": "25分钟", "ingredients": ["鸡胸肉", "花生", "胡萝卜", "大米"], "protein": 35, "tags": ["川菜", "经典"]},
{"name": "鱼香肉丝 + 米饭", "calories": 580, "time": "25分钟", "ingredients": ["猪肉", "木耳", "胡萝卜", "大米"], "protein": 28, "tags": ["川菜", "下饭"]},
{"name": "麻婆豆腐 + 米饭", "calories": 480, "time": "20分钟", "ingredients": ["豆腐", "猪肉末", "大米", "花椒"], "protein": 25, "tags": ["川菜", "素食可"]}
],
"dinner": [
{"name": "蒜蓉西兰花 + 烤鸡腿", "calories": 520, "time": "30分钟", "ingredients": ["西兰花", "鸡腿", "大蒜", "橄榄油"], "protein": 38, "tags": ["健康", "高蛋白"]},
{"name": "番茄鸡蛋汤 + 清炒藕片", "calories": 420, "time": "25分钟", "ingredients": ["西红柿", "鸡蛋", "莲藕", "青菜"], "protein": 22, "tags": ["清淡", "素食"]},
{"name": "清蒸鲈鱼 + 凉拌黄瓜", "calories": 480, "time": "30分钟", "ingredients": ["鲈鱼", "黄瓜", "蒜", "醋"], "protein": 36, "tags": ["清淡", "海鲜"]},
{"name": "肉末豆腐 + 米饭", "calories": 550, "time": "25分钟", "ingredients": ["猪肉", "豆腐", "大米", "葱"], "protein": 30, "tags": ["家常", "下饭"]},
{"name": "冬瓜排骨汤 + 炒青菜", "calories": 500, "time": "40分钟", "ingredients": ["排骨", "冬瓜", "青菜", "姜"], "protein": 28, "tags": ["滋补", "汤类"]},
{"name": "糖醋里脊 + 米饭", "calories": 650, "time": "30分钟", "ingredients": ["猪里脊", "大米", "醋", "糖"], "protein": 30, "tags": ["酸甜", "孩子爱"]},
{"name": "水煮牛肉 + 青菜", "calories": 550, "time": "35分钟", "ingredients": ["牛肉", "豆芽", "青菜", "辣椒"], "protein": 40, "tags": ["川菜", "麻辣"]},
{"name": "素炒时蔬 + 紫菜蛋花汤", "calories": 350, "time": "20分钟", "ingredients": ["西兰花", "胡萝卜", "紫菜", "鸡蛋"], "protein": 18, "tags": ["素食", "清淡"]}
]
}
}
FILE:engine/types.py
"""
Type definitions for Family Nutrition Planner
"""
from typing import Literal, Optional
from typing_extensions import TypedDict
class GenderType(TypedDict):
gender: Literal["male", "female", "other"]
class ActivityLevelType(TypedDict):
activityLevel: Literal["sedentary", "lightly-active", "moderately-active", "very-active", "extra-active"]
class GoalType(TypedDict):
type: Literal["balance", "low-carb", "high-protein", "weight-loss", "muscle-gain"]
class FamilyMember(TypedDict):
name: str
age: int
gender: GenderType
weight: float # kg
height: float # cm
activityLevel: ActivityLevelType
goals: Optional[list[str]]
allergies: Optional[list[str]]
class Preferences(TypedDict, total=False):
cuisine: list[str]
cookingStyle: list[str]
avoid: list[str]
class Constraints(TypedDict, total=False):
weeklyBudget: float
maxPrepTime: int
servingSize: int
class NutritionPlanRequest(TypedDict):
familyName: Optional[str]
members: list[FamilyMember]
preferences: Optional[Preferences]
constraints: Optional[Constraints]
goals: Optional[GoalType]
class MacronutrientBalance(TypedDict):
protein: str
carbohydrates: str
fat: str
class NutritionSummary(TypedDict):
averageDailyCalories: float
macronutrientBalance: MacronutrientBalance
foodVariety: int
class MealInfo(TypedDict):
name: str
calories: int
prepTime: str
ingredients: list[str]
class Meals(TypedDict):
breakfast: MealInfo
lunch: MealInfo
dinner: MealInfo
snacks: Optional[list[MealInfo]]
class DailyMacros(TypedDict):
calories: int
protein: str
carbohydrates: str
fat: str
class DailyPlan(TypedDict):
day: str
meals: Meals
dailyNutrition: DailyMacros
class CategoryItem(TypedDict):
category: str
items: list[dict] # Simplified
class ShoppingList(TypedDict):
categories: list[CategoryItem]
estimatedCost: int
savingsTips: list[str]
class NutritionAnalysis(TypedDict):
strengths: list[str]
concerns: list[str]
suggestions: list[str]
class NutritionPlanReport(TypedDict):
success: bool
nutritionSummary: NutritionSummary
dailyPlans: list[DailyPlan]
shoppingList: ShoppingList
nutritionAnalysis: NutritionAnalysis
weeklyNutritionTrend: str
FILE:handler.py
#!/usr/bin/env python3
"""
Family Nutrition Planner - Handler
Main entry point for the nutrition planning skill.
"""
import json
import random
from datetime import datetime
from typing import Dict, List, Any, Optional
# ==================== Types ====================
class NutritionPlanRequest:
"""Request format for nutrition planning."""
def __init__(self, data: Dict[str, Any]):
self.family_name = data.get("familyName", "我的家庭")
self.members = data.get("members", [])
self.preferences = data.get("preferences", {})
self.constraints = data.get("constraints", {})
self.goals = data.get("goals", {"type": "balance"})
class NutritionCalculator:
"""Calculate nutritional needs based on member profiles."""
ACTIVITY_MULTIPLIERS = {
"sedentary": 1.2,
"lightly-active": 1.375,
"moderately-active": 1.55,
"very-active": 1.725,
"extra-active": 1.9
}
GOAL_ADJUSTMENTS = {
"balance": 0,
"low-carb": -0.1,
"high-protein": 0.05,
"weight-loss": -0.2,
"muscle-gain": 0.15
}
MACRO_RATIOS = {
"balance": {"protein": 0.20, "carbs": 0.50, "fat": 0.30},
"low-carb": {"protein": 0.25, "carbs": 0.30, "fat": 0.45},
"high-protein": {"protein": 0.30, "carbs": 0.40, "fat": 0.30},
"weight-loss": {"protein": 0.30, "carbs": 0.40, "fat": 0.30},
"muscle-gain": {"protein": 0.30, "carbs": 0.45, "fat": 0.25}
}
@staticmethod
def calculate_bmr(weight: float, height: float, age: int, gender: str) -> float:
"""Calculate Basal Metabolic Rate using Mifflin-St Jeor equation."""
if gender == "male":
return 10 * weight + 6.25 * height - 5 * age + 5
else:
return 10 * weight + 6.25 * height - 5 * age - 161
@staticmethod
def calculate_tdee(bmr: float, activity_level: str) -> float:
"""Calculate Total Daily Energy Expenditure."""
multiplier = NutritionCalculator.ACTIVITY_MULTIPLIERS.get(activity_level, 1.2)
return bmr * multiplier
def calculate_member_needs(self, member: Dict) -> Dict[str, Any]:
"""Calculate nutritional needs for a single member."""
bmr = self.calculate_bmr(
member["weight"],
member["height"],
member["age"],
member["gender"]
)
tdee = self.calculate_tdee(bmr, member.get("activityLevel", "sedentary"))
goal_type = self.goals.get("type", "balance")
adjustment = self.GOAL_ADJUSTMENTS.get(goal_type, 0)
target = tdee * (1 + adjustment)
macros = self.MACRO_RATIOS.get(goal_type, self.MACRO_RATIOS["balance"])
return {
"name": member["name"],
"bmr": round(bmr, 1),
"tdee": round(tdee, 1),
"target": round(target, 1),
"macros": {
"protein_grams": round(target * macros["protein"] / 4, 1),
"carbs_grams": round(target * macros["carbs"] / 4, 1),
"fat_grams": round(target * macros["fat"] / 9, 1)
}
}
class MealPlanGenerator:
"""Generate weekly meal plans."""
# Mock recipe database
RECIPES = {
"breakfast": [
{"name": "全麦吐司配鸡蛋牛油果", "calories": 420, "time": "15分钟",
"ingredients": ["全麦吐司", "鸡蛋", "牛油果", "西红柿"], "protein": 18},
{"name": "燕麦粥配水果", "calories": 350, "time": "10分钟",
"ingredients": ["燕麦", "牛奶", "香蕉", "蓝莓"], "protein": 12},
{"name": "中式豆腐脑", "calories": 280, "time": "20分钟",
"ingredients": ["黄豆", "内酯豆腐", "葱花", "酱油"], "protein": 22},
{"name": "蔬菜鸡蛋饼", "calories": 380, "time": "15分钟",
"ingredients": ["鸡蛋", "面粉", "青菜", "胡萝卜"], "protein": 16},
{"name": "牛奶鸡蛋羹", "calories": 320, "time": "15分钟",
"ingredients": ["鸡蛋", "牛奶", "葱花"], "protein": 20},
],
"lunch": [
{"name": "清蒸鲈鱼 + 米饭 + 青菜", "calories": 580, "time": "25分钟",
"ingredients": ["鲈鱼", "大米", "青菜", "姜"], "protein": 35},
{"name": "鸡胸肉沙拉配藜麦", "calories": 520, "time": "20分钟",
"ingredients": ["鸡胸肉", "混合蔬菜", "藜麦", "橄榄油"], "protein": 40},
{"name": "番茄牛腩面", "calories": 620, "time": "35分钟",
"ingredients": ["牛肉", "西红柿", "面条", "洋葱"], "protein": 32},
{"name": "红烧肉 + 米饭", "calories": 720, "time": "40分钟",
"ingredients": ["五花肉", "大米", "土豆", "酱油"], "protein": 28},
{"name": "虾仁炒饭", "calories": 550, "time": "20分钟",
"ingredients": ["虾仁", "米饭", "鸡蛋", "豌豆"], "protein": 30},
],
"dinner": [
{"name": "蒜蓉西兰花 + 烤鸡腿", "calories": 520, "time": "30分钟",
"ingredients": ["西兰花", "鸡腿", "大蒜", "橄榄油"], "protein": 38},
{"name": "番茄鸡蛋汤 + 清炒藕片", "calories": 420, "time": "25分钟",
"ingredients": ["西红柿", "鸡蛋", "莲藕", "青菜"], "protein": 22},
{"name": "清蒸鲈鱼 + 凉拌黄瓜", "calories": 480, "time": "30分钟",
"ingredients": ["鲈鱼", "黄瓜", "蒜", "醋"], "protein": 36},
{"name": "肉末豆腐 + 米饭", "calories": 550, "time": "25分钟",
"ingredients": ["猪肉", "豆腐", "大米", "葱"], "protein": 30},
{"name": "冬瓜排骨汤 + 炒青菜", "calories": 500, "time": "40分钟",
"ingredients": ["排骨", "冬瓜", "青菜", "姜"], "protein": 28},
]
}
DAYS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
def __init__(self, preferences: Dict, constraints: Dict, goals: Dict):
self.preferences = preferences
self.constraints = constraints
self.goals = goals
self.max_prep_time = constraints.get("maxPrepTime", 60)
self.weekly_budget = constraints.get("weeklyBudget", 500)
def generate_daily_plan(self, day: str) -> Dict[str, Any]:
"""Generate a single day's meal plan."""
breakfast = random.choice(self.RECIPES["breakfast"])
lunch = random.choice(self.RECIPES["lunch"])
dinner = random.choice(self.RECIPES["dinner"])
total_cal = breakfast["calories"] + lunch["calories"] + dinner["calories"]
return {
"day": day,
"meals": {
"breakfast": {
"name": breakfast["name"],
"calories": breakfast["calories"],
"prepTime": breakfast["time"],
"ingredients": breakfast["ingredients"]
},
"lunch": {
"name": lunch["name"],
"calories": lunch["calories"],
"prepTime": lunch["time"],
"ingredients": lunch["ingredients"]
},
"dinner": {
"name": dinner["name"],
"calories": dinner["calories"],
"prepTime": dinner["time"],
"ingredients": dinner["ingredients"]
}
},
"dailyNutrition": {
"calories": total_cal,
"protein": "约{}g".format(
breakfast.get("protein", 15) + lunch.get("protein", 30) + dinner.get("protein", 30)
),
"carbohydrates": "约200g",
"fat": "约60g"
}
}
def generate_weekly_plan(self) -> List[Dict[str, Any]]:
"""Generate a 7-day meal plan."""
return [self.generate_daily_plan(day) for day in self.DAYS]
class ShoppingListGenerator:
"""Generate shopping list from meal plan."""
def __init__(self, weekly_plan: List[Dict], budget: float):
self.weekly_plan = weekly_plan
self.budget = budget
def generate(self) -> Dict[str, Any]:
"""Generate categorized shopping list."""
all_ingredients = []
for day_plan in self.weekly_plan:
for meal_type, meal in day_plan["meals"].items():
all_ingredients.extend(meal.get("ingredients", []))
categories = {
"肉类": ["鸡腿", "鸡胸肉", "鲈鱼", "虾仁", "猪肉", "排骨", "牛肉", "五花肉"],
"蔬菜": ["青菜", "西兰花", "黄瓜", "西红柿", "胡萝卜", "莲藕", "冬瓜", "洋葱", "姜", "蒜", "葱"],
"主食": ["大米", "面条", "燕麦", "全麦吐司", "藜麦"],
"蛋奶": ["鸡蛋", "牛奶", "内酯豆腐"],
"豆制品": ["黄豆", "豆腐"],
"水果": ["香蕉", "蓝莓", "牛油果"],
"调料": ["酱油", "醋", "橄榄油", "葱花"]
}
shopping_items = []
for category, items in categories.items():
cat_items = [item for item in items if item in all_ingredients]
if cat_items:
shopping_items.append({
"category": category,
"items": [
{"name": item, "quantity": "适量", "estimatedCost": random.randint(5, 30)}
for item in cat_items
]
})
estimated_cost = sum(
sum(item["estimatedCost"] for item in cat["items"])
for cat in shopping_items
)
savings_tips = [
"周末一次性采购可以节省时间",
"选择当季蔬菜可以降低成本",
"大型超市比便利店便宜约20%",
"关注会员特价活动"
]
return {
"categories": shopping_items,
"estimatedCost": estimated_cost,
"savingsTips": savings_tips
}
class AllergyChecker:
"""Check for allergens in meal plans."""
COMMON_ALLERGENS = ["花生", "虾", "牛奶", "鸡蛋", "小麦", "大豆", "坚果"]
@staticmethod
def check_ingredients(ingredients: List[str], allergies: List[str]) -> List[str]:
"""Check if any ingredients contain allergens."""
found_allergens = []
for allergen in allergies:
for ingredient in ingredients:
if allergen in ingredient:
found_allergens.append(allergen)
return found_allergens
@staticmethod
def filter_safe_recipes(recipes: List[Dict], allergies: List[str]) -> List[Dict]:
"""Filter out recipes containing allergens."""
if not allergies:
return recipes
safe_recipes = []
for recipe in recipes:
all_ingredients = recipe.get("ingredients", [])
has_allergen = False
for allergen in allergies:
if any(allergen in ing for ing in all_ingredients):
has_allergen = True
break
if not has_allergen:
safe_recipes.append(recipe)
return safe_recipes
def handle_nutrition_request(request_data: Dict[str, Any]) -> Dict[str, Any]:
"""Main handler for nutrition planning requests."""
request = NutritionPlanRequest(request_data)
calculator = NutritionCalculator()
calculator.goals = request.goals
member_needs = []
all_allergies = set()
total_tdee = 0
for member in request.members:
needs = calculator.calculate_member_needs(member)
member_needs.append(needs)
total_tdee += needs["target"]
if member.get("allergies"):
all_allergies.update(member["allergies"])
avg_daily_cal = total_tdee / len(request.members) if request.members else 2000
meal_generator = MealPlanGenerator(
request.preferences,
request.constraints,
request.goals
)
weekly_plan = meal_generator.generate_weekly_plan()
allergen_warnings = []
for day_plan in weekly_plan:
for meal_type, meal in day_plan["meals"].items():
found = AllergyChecker.check_ingredients(
meal.get("ingredients", []),
list(all_allergies)
)
if found:
allergen_warnings.append(f"{day_plan['day']}{meal_type}: 发现过敏原 {', '.join(found)}")
shopping_gen = ShoppingListGenerator(weekly_plan, request.constraints.get("weeklyBudget", 500))
shopping_list = shopping_gen.generate()
goal_type = request.goals.get("type", "balance")
macro_ratios = NutritionCalculator.MACRO_RATIOS.get(goal_type, NutritionCalculator.MACRO_RATIOS["balance"])
unique_ingredients = set()
for day_plan in weekly_plan:
for meal in day_plan["meals"].values():
unique_ingredients.update(meal.get("ingredients", []))
response = {
"success": True,
"nutritionSummary": {
"averageDailyCalories": round(avg_daily_cal, 0),
"macronutrientBalance": {
"protein": "{}%".format(int(macro_ratios["protein"] * 100)),
"carbohydrates": "{}%".format(int(macro_ratios["carbs"] * 100)),
"fat": "{}%".format(int(macro_ratios["fat"] * 100))
},
"foodVariety": len(unique_ingredients)
},
"dailyPlans": weekly_plan,
"shoppingList": shopping_list,
"nutritionAnalysis": {
"strengths": [
"每日三餐营养均衡",
"包含多种蛋白质来源(鱼、肉、豆制品)",
"蔬菜摄入充足",
"食材多样性良好({}种食材)".format(len(unique_ingredients))
],
"concerns": allergen_warnings if allergen_warnings else [],
"suggestions": [
"建议每天饮用300-500ml牛奶或酸奶",
"适量增加全谷物摄入",
"每周安排2-3次鱼类摄入"
]
},
"weeklyNutritionTrend": "本周营养摄入整体均衡,蛋白质和蔬菜摄入充足,建议继续保持多样化的饮食结构。",
"memberNutritionNeeds": member_needs
}
return response
def handle_nutrition_calculation(member_data: Dict[str, Any]) -> Dict[str, Any]:
"""Calculate nutritional needs for a single member."""
calculator = NutritionCalculator()
calculator.goals = {"type": member_data.get("goalType", "balance")}
needs = calculator.calculate_member_needs(member_data)
goal_type = member_data.get("goalType", "balance")
macro_ratios = NutritionCalculator.MACRO_RATIOS.get(goal_type, NutritionCalculator.MACRO_RATIOS["balance"])
return {
"success": True,
"member": needs["name"],
"bmr": needs["bmr"],
"tdee": needs["tdee"],
"targetIntake": needs["target"],
"macronutrientTargets": {
"protein": {
"grams": needs["macros"]["protein_grams"],
"calories": round(needs["macros"]["protein_grams"] * 4, 1),
"percentage": int(macro_ratios["protein"] * 100)
},
"carbohydrates": {
"grams": needs["macros"]["carbs_grams"],
"calories": round(needs["macros"]["carbs_grams"] * 4, 1),
"percentage": int(macro_ratios["carbs"] * 100)
},
"fat": {
"grams": needs["macros"]["fat_grams"],
"calories": round(needs["macros"]["fat_grams"] * 9, 1),
"percentage": int(macro_ratios["fat"] * 100)
}
},
"recommendations": [
"蛋白质分布在每餐 25-40g 有助于最佳吸收",
"训练后碳水摄入为主有助于恢复",
"适量健康脂肪(坚果、橄榄油)有益心血管健康"
]
}
if __name__ == "__main__":
test_request = {
"familyName": "张氏家庭",
"members": [
{"name": "爸爸", "age": 35, "gender": "male", "weight": 75, "height": 175, "activityLevel": "lightly-active", "allergies": []},
{"name": "妈妈", "age": 32, "gender": "female", "weight": 58, "height": 162, "activityLevel": "lightly-active", "allergies": []},
{"name": "孩子", "age": 8, "gender": "male", "weight": 28, "height": 130, "activityLevel": "moderately-active", "allergies": ["虾"]}
],
"preferences": {"cuisine": ["中式"], "cookingStyle": ["少油", "清淡"]},
"constraints": {"weeklyBudget": 500, "maxPrepTime": 60, "servingSize": 4},
"goals": {"type": "balance"}
}
print("=" * 50)
print("Family Nutrition Planner - Test")
print("=" * 50)
print()
result = handle_nutrition_request(test_request)
print(json.dumps(result, ensure_ascii=False, indent=2))
FILE:package.json
{
"name": "family-nutrition-planner",
"version": "0.1.0",
"description": "Family Nutrition Planner - Generate weekly meal plans and shopping lists",
"type": "module",
"main": "handler.py",
"scripts": {
"test": "python scripts/test-handler.py"
}
}
FILE:scripts/test-handler.py
#!/usr/bin/env python3
"""
Test script for Family Nutrition Planner handler.
"""
import sys
sys.path.insert(0, '/Users/jianghaidong/.openclaw/skills/family-nutrition-planner')
from handler import handle_nutrition_request, handle_nutrition_calculation
# Test 1: Weekly meal plan generation
print("=" * 60)
print("Test 1: Weekly Meal Plan Generation")
print("=" * 60)
test_request = {
"familyName": "张氏家庭",
"members": [
{"name": "爸爸", "age": 35, "gender": "male", "weight": 75, "height": 175, "activityLevel": "lightly-active", "allergies": []},
{"name": "妈妈", "age": 32, "gender": "female", "weight": 58, "height": 162, "activityLevel": "lightly-active", "allergies": []},
{"name": "孩子", "age": 8, "gender": "male", "weight": 28, "height": 130, "activityLevel": "moderately-active", "allergies": ["虾"]}
],
"preferences": {"cuisine": ["中式"], "cookingStyle": ["少油", "清淡"]},
"constraints": {"weeklyBudget": 500, "maxPrepTime": 60, "servingSize": 4},
"goals": {"type": "balance"}
}
result = handle_nutrition_request(test_request)
print(f"Success: {result['success']}")
print(f"Average Daily Calories: {result['nutritionSummary']['averageDailyCalories']}")
print(f"Macro Balance: {result['nutritionSummary']['macronutrientBalance']}")
print(f"Food Variety: {result['nutritionSummary']['foodVariety']} ingredients")
print(f"Daily Plans Count: {len(result['dailyPlans'])}")
print(f"Shopping Categories: {len(result['shoppingList']['categories'])}")
print(f"Estimated Cost: {result['shoppingList']['estimatedCost']}元")
print(f"Allergen Warnings: {result['nutritionAnalysis']['concerns']}")
# Check first day plan
if result['dailyPlans']:
first_day = result['dailyPlans'][0]
print(f"\nFirst Day Plan ({first_day['day']}):")
for meal_type, meal in first_day['meals'].items():
print(f" {meal_type}: {meal['name']} ({meal['calories']} kcal)")
print("\n" + "=" * 60)
print("Test 2: Single Member Nutrition Calculation")
print("=" * 60)
member_test = {
"name": "测试用户",
"age": 30,
"gender": "male",
"weight": 70,
"height": 175,
"activityLevel": "moderately-active",
"goalType": "muscle-gain"
}
calc_result = handle_nutrition_calculation(member_test)
print(f"Success: {calc_result['success']}")
print(f"Member: {calc_result['member']}")
print(f"BMR: {calc_result['bmr']} kcal")
print(f"TDEE: {calc_result['tdee']} kcal")
print(f"Target Intake: {calc_result['targetIntake']} kcal")
print(f"Macros:")
print(f" Protein: {calc_result['macronutrientTargets']['protein']['grams']}g ({calc_result['macronutrientTargets']['protein']['percentage']}%)")
print(f" Carbs: {calc_result['macronutrientTargets']['carbohydrates']['grams']}g ({calc_result['macronutrientTargets']['carbohydrates']['percentage']}%)")
print(f" Fat: {calc_result['macronutrientTargets']['fat']['grams']}g ({calc_result['macronutrientTargets']['fat']['percentage']}%)")
print("\n" + "=" * 60)
print("All Tests Passed!")
print("=" * 60)
AI companion offering evidence-based child development assessments, positive parenting guidance, age-appropriate activities, behavior analysis, and routine s...
# Parenting Growth Partner / 育儿成长伙伴
## Overview
Parenting Growth Partner is an AI-powered companion for parents and caregivers, providing evidence-based guidance on child development, positive parenting techniques, and age-appropriate activities. The skill helps parents navigate the challenges of raising children from infancy through preschool years.
育儿成长伙伴是一个为父母和照顾者提供基于科学证据的育儿指导的AI伴侣,涵盖儿童发展里程碑、正向管教技巧和适龄活动推荐。帮助父母应对从婴儿期到学龄前儿童的育儿挑战。
## Core Features
### 1. Child Development Milestone Tracking
- Assess developmental milestones across 6 domains: gross motor, fine motor, language, cognitive, social-emotional, adaptive
- Identify potential red flags based on age
- Generate personalized recommendations
### 2. Age-Appropriate Activity Recommendations
- Recommend developmentally appropriate activities
- Filter by available time and preferred developmental domains
- Include safety notes and step-by-step instructions
### 3. Positive Communication Guidance
- Provide evidence-based techniques for common parenting scenarios
- Offer age-appropriate communication scripts
- Help prevent common communication mistakes
### 4. Behavior Analysis & Positive Discipline
- Analyze behavior patterns and underlying causes
- Recommend positive discipline techniques
- Create customized behavior intervention plans
### 5. Daily Routine Suggestions
- Suggest age-appropriate daily schedules
- Provide tips for establishing healthy routines
- Offer flexibility guidelines
## Input/Output
### Input Parameters
```json
{
"action": "milestone_assessment|activity_recommendation|communication_guidance|behavior_analysis|daily_routine",
"params": {
"age_months": 24,
"observations": {"gross-motor": ["walks well", "can climb stairs"]},
"available_time": 30,
"preferred_domains": ["cognitive", "fine-motor"],
"scenario": "tantrum|refusal|sharing|bedtime",
"behavior_description": "经常说'不',拖延",
"frequency": "occasional|frequent|constant",
"context": "被要求做事时"
}
}
```
### Output Structure
```json
{
"success": true,
"assessment": {...},
"recommendations": [...],
"summary": {...}
}
```
## Handler Functions
### `handle_milestone_assessment(age_months, observations)`
- **Purpose**: Assess child's developmental progress
- **Parameters**:
- `age_months`: Child's age in months (0-60)
- `observations`: Optional dictionary of observed behaviors by domain
- **Returns**: Assessment results with achieved milestones, upcoming milestones, and red flags
### `handle_activity_recommendation(age_months, available_time, preferred_domains)`
- **Purpose**: Recommend developmentally appropriate activities
- **Parameters**:
- `age_months`: Child's age in months
- `available_time`: Available time in minutes (default: 30)
- `preferred_domains`: Optional list of developmental domains to focus on
- **Returns**: List of suitable activities with details
### `handle_communication_guidance(scenario, child_age_months)`
- **Purpose**: Provide communication strategies for common parenting scenarios
- **Parameters**:
- `scenario`: One of: tantrum, refusal, sharing, bedtime
- `child_age_months`: Optional child's age for age-specific advice
- **Returns**: Communication techniques, example scripts, and common mistakes to avoid
### `handle_behavior_analysis(behavior_description, frequency, context, child_age_months)`
- **Purpose**: Analyze behavior patterns and recommend positive discipline
- **Parameters**:
- `behavior_description`: Description of the behavior
- `frequency`: How often it occurs
- `context`: When/where it happens
- `child_age_months`: Optional child's age for age-appropriate strategies
- **Returns**: Behavior analysis, possible patterns, and positive discipline plan
### `handle_daily_routine_suggestion(child_age_months)`
- **Purpose**: Suggest age-appropriate daily routines
- **Parameters**:
- `child_age_months`: Child's age in months
- **Returns**: Suggested routine schedule and implementation tips
## Usage Examples
### Example 1: Milestone Assessment
```python
from handler import ParentingGrowthPartner
partner = ParentingGrowthPartner()
result = partner.handle_milestone_assessment(
age_months=18,
observations={"language": ["says mama", "understands no"]}
)
print(f"Development status: {result['assessment']['summary']['development_status']}")
```
### Example 2: Activity Recommendation
```python
result = partner.handle_activity_recommendation(
age_months=30,
available_time=20,
preferred_domains=["fine-motor", "cognitive"]
)
for activity in result['recommendations']['recommended_activities'][:2]:
print(f"- {activity['name']} ({activity['duration_minutes']} min)")
```
### Example 3: Communication Guidance
```python
result = partner.handle_communication_guidance(
scenario="tantrum",
child_age_months=24
)
for technique in result['guidance']['techniques']:
print(f"Technique: {technique['name']}")
print(f"Example: {technique['example_scripts']['effective']}")
```
## Safety & Considerations
### Developmental Variability
- Children develop at different paces
- Milestones are guidelines, not strict deadlines
- Always consult professionals for concerns
### Cultural Sensitivity
- Parenting practices vary across cultures
- Recommendations should be adapted to family values
- Respect diverse parenting styles
### Professional Consultation
- This tool is for informational purposes only
- Not a substitute for professional medical or psychological advice
- Seek professional help for serious concerns
## Data Sources & References
### Developmental Milestones
- Based on CDC Developmental Milestones
- WHO Child Growth Standards
- American Academy of Pediatrics guidelines
### Positive Parenting Techniques
- Positive Discipline (Jane Nelsen)
- Conscious Parenting
- Evidence-based parenting interventions
### Activity Recommendations
- Developmentally Appropriate Practice (NAEYC)
- Montessori principles
- Play-based learning research
## Testing
Run self-test:
```bash
cd ~/.openclaw/skills/parenting-growth-partner
python3 handler.py
```
Expected output includes 5 test cases with success indicators.
## File Structure
```
parenting-growth-partner/
├── SKILL.md # This documentation
├── handler.py # Main handler with self-test
├── skill.json # Skill metadata
├── .claw/identity.json # Identity configuration
├── engine/ # Core engines
│ ├── __init__.py
│ ├── milestones.py # Milestone tracking engine
│ ├── activities.py # Activity recommendation engine
│ ├── communication.py # Communication guidance engine
│ └── behavior.py # Behavior analysis engine
└── scripts/
└── test-handler.py # Additional test scripts
```
## Version History
- v0.1.0: Initial release with 5 core features
- v0.2.0: Planned: Sleep guidance, feeding advice, sibling rivalry
## Support & Feedback
For issues or suggestions, please contact the skill maintainer.
FILE:clawhub.json
{
"name": "parenting-growth-partner",
"version": "0.1.0",
"description": "Parenting Growth Partner / 亲子成长伙伴 - 智能育儿支持系统,帮助家长科学陪伴孩子成长",
"keywords": ["parenting","child-development","education","family","growth"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/parenting-growth-partner"
}
FILE:data/activity_templates.json
{
"activity_categories": [
{
"category": "sensory_play",
"description": "Activities that engage the senses",
"examples": ["water play", "sand play", "play dough", "texture boards"]
},
{
"category": "motor_skills",
"description": "Activities that develop gross and fine motor skills",
"examples": ["ball games", "building blocks", "puzzles", "threading beads"]
},
{
"category": "cognitive_development",
"description": "Activities that support thinking and problem-solving",
"examples": ["sorting games", "matching games", "simple puzzles", "cause-effect toys"]
},
{
"category": "language_development",
"description": "Activities that support communication skills",
"examples": ["reading books", "singing songs", "storytelling", "conversation games"]
},
{
"category": "social_emotional",
"description": "Activities that support social skills and emotional regulation",
"examples": ["role play", "cooperative games", "emotion cards", "turn-taking games"]
}
],
"safety_guidelines": [
"Always supervise children during activities",
"Choose age-appropriate materials",
"Check for choking hazards (small parts)",
"Ensure materials are non-toxic",
"Create a safe play environment"
]
}
FILE:data/development_references.json
{
"sources": [
{
"name": "CDC Developmental Milestones",
"url": "https://www.cdc.gov/ncbddd/actearly/milestones/index.html",
"description": "Centers for Disease Control and Prevention developmental milestone checklists"
},
{
"name": "WHO Child Growth Standards",
"url": "https://www.who.int/tools/child-growth-standards",
"description": "World Health Organization growth and development standards"
},
{
"name": "American Academy of Pediatrics",
"url": "https://www.healthychildren.org",
"description": "AAP guidelines for child development and parenting"
}
],
"age_ranges": [
"0-3 months",
"3-6 months",
"6-12 months",
"12-24 months",
"24-36 months",
"36-60 months"
],
"developmental_domains": [
"gross-motor",
"fine-motor",
"language",
"cognitive",
"social-emotional",
"adaptive"
]
}
FILE:engine/__init__.py
"""
Parenting Growth Partner Engine Modules
"""
__all__ = [
'MilestoneEngine',
'ActivityEngine',
'CommunicationEngine',
'BehaviorEngine'
]
from .milestones import MilestoneEngine
from .activities import ActivityEngine
from .communication import CommunicationEngine
from .behavior import BehaviorEngine
FILE:engine/activities.py
"""
ActivityEngine - Age-Appropriate Activity Recommendation Engine
"""
from typing import Dict, List
ACTIVITY_DB = {
(0, 3): [
{'id': 'act_0_3_1', 'name': '追视练习', 'domains': ['cognitive', 'gross-motor'],
'difficulty': 'easy', 'duration_minutes': 5,
'materials': ['黑白卡', '红球'],
'steps': ['将黑白卡放在宝宝视线前20-30cm', '缓慢左右移动卡片,观察宝宝眼睛跟随', '每天2-3次,每次3-5分钟'],
'safety_notes': ['确保卡片边缘光滑', '不要在宝宝哭闹时进行']},
{'id': 'act_0_3_2', 'name': '俯趴抬头', 'domains': ['gross-motor'],
'difficulty': 'easy', 'duration_minutes': 10,
'materials': ['软垫', '彩色玩具'],
'steps': ['在软垫上让宝宝俯卧', '用彩色玩具在前方吸引注意力', '逐渐延长时间'],
'safety_notes': ['选择宝宝清醒时进行', '注意观察是否疲劳']}
],
(3, 6): [
{'id': 'act_3_6_1', 'name': '抓握游戏', 'domains': ['fine-motor', 'cognitive'],
'difficulty': 'easy', 'duration_minutes': 10,
'materials': ['摇铃', '布书', '软积木'],
'steps': ['选择不同材质的玩具让宝宝抓握', '描述玩具的颜色、材质', '鼓励换手传递'],
'safety_notes': ['玩具需无毒、大件(防止误吞)']},
{'id': 'act_3_6_2', 'name': '翻身练习', 'domains': ['gross-motor'],
'difficulty': 'medium', 'duration_minutes': 15,
'materials': ['软垫', '吸引注意力的玩具'],
'steps': ['宝宝仰卧,在侧面用玩具引导', '轻轻推送宝宝侧身', '逐渐减少辅助直至独立翻身'],
'safety_notes': ['选择柔软地面', '不要在刚吃完奶后进行']}
],
(6, 12): [
{'id': 'act_6_12_1', 'name': '爬行隧道', 'domains': ['gross-motor', 'cognitive'],
'difficulty': 'medium', 'duration_minutes': 20,
'materials': ['纸箱', '毯子', '玩具'],
'steps': ['用纸箱和毯子搭建小隧道', '在另一端放玩具吸引爬行', '家庭成员轮流在隧道另一端呼唤'],
'safety_notes': ['确保纸箱边缘光滑无刺', '全程看护']},
{'id': 'act_6_12_2', 'name': '敲击乐器', 'domains': ['fine-motor', 'cognitive', 'language'],
'difficulty': 'easy', 'duration_minutes': 15,
'materials': ['塑料碗', '木勺', '空奶粉罐'],
'steps': ['示范敲击不同物体发出不同声音', '让宝宝自由探索敲击', '配合简单节拍念唱'],
'safety_notes': ['确保容器无尖锐边缘', '防止敲击到手指']},
{'id': 'act_6_12_3', 'name': '躲猫猫', 'domains': ['cognitive', 'social-emotional'],
'difficulty': 'easy', 'duration_minutes': 10,
'materials': ['毯子', '毛绒玩具'],
'steps': ['用毯子遮住脸问宝宝在哪', '掀开毯子说找到啦', '用玩具重复此游戏'],
'safety_notes': ['确保毯子透气轻薄']}
],
(12, 24): [
{'id': 'act_12_24_1', 'name': '积木叠叠乐', 'domains': ['fine-motor', 'cognitive'],
'difficulty': 'medium', 'duration_minutes': 20,
'materials': ['软积木', '套圈玩具'],
'steps': ['示范叠高2-3块积木', '鼓励宝宝模仿', '数数:一块、两块、三块'],
'safety_notes': ['选择轻质软积木防止砸脚']},
{'id': 'act_12_24_2', 'name': '假想过家家', 'domains': ['cognitive', 'language', 'social-emotional'],
'difficulty': 'easy', 'duration_minutes': 15,
'materials': ['玩具厨房', '塑料食物', '餐具'],
'steps': ['设置假想场景如做饭', '引导宝宝给玩偶喂饭', '描述正在进行的动作'],
'safety_notes': ['玩具食材需足够大防止误吞']},
{'id': 'act_12_24_3', 'name': '户外感官探索', 'domains': ['gross-motor', 'cognitive'],
'difficulty': 'easy', 'duration_minutes': 30,
'materials': ['沙坑', '草地'],
'steps': ['带宝宝在安全环境中自由探索', '描述触感和感受:沙子是细细的、草是软软的', '注意安全,不让宝宝将异物放入口中'],
'safety_notes': ['户外需全程看护', '注意防晒防蚊']}
],
(24, 36): [
{'id': 'act_24_36_1', 'name': '角色扮演游戏', 'domains': ['cognitive', 'language', 'social-emotional'],
'difficulty': 'medium', 'duration_minutes': 25,
'materials': ['道具服装', '玩偶', '玩具家具'],
'steps': ['确定一个场景如医生看病', '家长示范如何与玩偶互动', '让宝宝主导游戏过程'],
'safety_notes': ['道具需安全无小零件']},
{'id': 'act_24_36_2', 'name': '简单粘贴画', 'domains': ['fine-motor', 'cognitive', 'creative'],
'difficulty': 'easy', 'duration_minutes': 20,
'materials': ['彩纸', '胶水', '画笔'],
'steps': ['示范撕纸动作', '让宝宝自由撕扯彩纸', '一起将撕下的纸片贴在另一张纸上组成图案'],
'safety_notes': ['使用儿童安全剪刀,需在成人指导下使用']},
{'id': 'act_24_36_3', 'name': '球类互动', 'domains': ['gross-motor', 'social-emotional'],
'difficulty': 'easy', 'duration_minutes': 20,
'materials': ['不同大小的球'],
'steps': ['示范踢球、抛球、接球', '从近距离开始逐渐增加距离', '鼓励与同伴一起玩'],
'safety_notes': ['选择柔软的球', '注意周围无尖锐物']}
],
(36, 60): [
{'id': 'act_36_60_1', 'name': '合作搭建', 'domains': ['cognitive', 'social-emotional', 'fine-motor'],
'difficulty': 'hard', 'duration_minutes': 40,
'materials': ['积木', '磁力片', '纸箱'],
'steps': ['设定搭建目标如建一座桥', '分工合作完成搭建', '讨论遇到的问题和解决方法'],
'safety_notes': ['积木需稳固防止倒塌砸伤']},
{'id': 'act_36_60_2', 'name': '棋盘游戏', 'domains': ['cognitive', 'social-emotional'],
'difficulty': 'medium', 'duration_minutes': 30,
'materials': ['简单桌游', '飞行棋', '记忆卡牌'],
'steps': ['选择适合年龄的桌游', '讲解规则并示范', '鼓励轮流进行,正确对待输赢'],
'safety_notes': ['游戏配件需适合年龄,防止误吞']},
{'id': 'act_36_60_3', 'name': '科学小实验', 'domains': ['cognitive', 'science', 'fine-motor'],
'difficulty': 'medium', 'duration_minutes': 30,
'materials': ['白醋', '小苏打', '食用色素', '杯子'],
'steps': ['往杯中加入小苏打', '滴入几滴食用色素', '倒入白醋观察反应', '解释火山爆发的原理'],
'safety_notes': ['材料需安全无毒', '在成人监督下进行']}
]
}
class ActivityEngine:
def __init__(self):
self.name = 'ActivityEngine'
def get_age_range(self, age_months):
ranges = [(0, 3), (3, 6), (6, 12), (12, 24), (24, 36), (36, 60)]
for r in ranges:
if r[0] <= age_months < r[1]:
return r
return (36, 60)
def recommend_activities(self, age_months, available_time=30, preferred_domains=None):
age_range = self.get_age_range(age_months)
activities = ACTIVITY_DB.get(age_range, ACTIVITY_DB[(36, 60)])
suitable = []
for a in activities:
if a['duration_minutes'] <= available_time:
if preferred_domains is None or any(d in a['domains'] for d in preferred_domains):
suitable.append(a)
return {
'age_range': str(age_range[0]) + '-' + str(age_range[1]) + '个月',
'recommended_activities': suitable,
'summary': {'total_available': len(suitable), 'most_relevant': [a['name'] for a in suitable[:3]]}
}
def get_activity_detail(self, activity_id):
for activities in ACTIVITY_DB.values():
for a in activities:
if a['id'] == activity_id:
return a
return {}
FILE:engine/behavior.py
"""
BehaviorEngine - Child Behavior Analysis and Positive Discipline
"""
from typing import Dict, List
BEHAVIOR_PATTERNS = {
'attention-seeking': {
'description': '通过不当行为获取关注',
'triggers': ['家长忙碌时', '有客人来访时', '兄弟姐妹获得关注时'],
'typical_behaviors': ['故意捣乱', '大声喊叫', '重复提问'],
'positive_response': [
'主动给予关注:每天安排专属的“特别时光”',
'捕捉好行为:及时表扬安静、专注的时刻',
'教替代行为:教孩子如何礼貌地请求关注'
],
'mistakes_to_avoid': ['只在孩子捣乱时才关注', '过度反应强化行为', '忽视所有行为']
},
'power-struggle': {
'description': '通过反抗建立控制感',
'triggers': ['被命令时', '感觉被控制时', '自主权被侵犯时'],
'typical_behaviors': ['说“不”', '拖延', '故意做相反的事'],
'positive_response': [
'提供有限选择:给予2-3个可接受的选择',
'避免权力斗争:用“我注意到...”代替命令',
'赋予责任:让孩子承担适当的责任'
],
'mistakes_to_avoid': ['陷入争吵', '威胁惩罚', '情绪化反应']
},
'avoidance': {
'description': '逃避不喜欢的任务或情境',
'triggers': ['困难任务', '新环境', '社交压力'],
'typical_behaviors': ['拖延', '抱怨身体不适', '发脾气'],
'positive_response': [
'分解任务:将大任务拆分成小步骤',
'提供支持:陪伴孩子一起开始任务',
'使用计时器:明确任务时间和休息时间'
],
'mistakes_to_avoid': ['强迫立即完成', '批评拖延', '代替孩子完成任务']
},
'sensory-seeking': {
'description': '通过行为满足感官需求',
'triggers': ['无聊时', '需要刺激时', '过度兴奋时'],
'typical_behaviors': ['不停动来动去', '制造噪音', '触摸一切物品'],
'positive_response': [
'提供感官活动:如捏橡皮泥、玩沙、荡秋千',
'建立感官角:准备安全的感官玩具',
'安排充足活动:确保每天有足够的户外时间'
],
'mistakes_to_avoid': ['惩罚正常感官需求', '限制所有活动', '忽视孩子的需求']
}
}
POSITIVE_DISCIPLINE_TECHNIQUES = [
{
'name': '自然结果法',
'description': '让孩子体验行为的自然结果',
'when_to_use': '行为有明确、安全的自然结果时',
'steps': [
'提前告知可能的自然结果',
'允许孩子体验结果(在安全范围内)',
'事后温和讨论学到了什么'
],
'example': '“如果不穿外套,可能会觉得冷。”'
},
{
'name': '逻辑结果法',
'description': '设计与行为相关的合理结果',
'when_to_use': '行为没有明显自然结果时',
'steps': [
'结果必须与行为相关',
'提前告知结果',
'温和坚定地执行'
],
'example': '“玩具乱扔不收拾,明天就不能玩这些玩具。”'
},
{
'name': '积极暂停',
'description': '帮助孩子平静情绪,不是惩罚',
'when_to_use': '孩子情绪失控时',
'steps': [
'创建舒适的“冷静角”',
'教孩子情绪平静技巧',
'情绪平复后讨论解决方案'
],
'example': '“你需要去冷静角平静一下吗?我们可以一起深呼吸。”'
},
{
'name': '问题解决会议',
'description': '与孩子共同寻找解决方案',
'when_to_use': '重复出现的行为问题',
'steps': [
'选择平静的时间',
'轮流表达感受和需求',
'共同头脑风暴解决方案',
'选择并试行一个方案'
],
'example': '“我们都有点困扰睡前拖延,一起想想有什么办法能让睡前更顺利?”'
}
]
class BehaviorEngine:
def __init__(self):
self.name = 'BehaviorEngine'
def analyze_behavior(self, behavior_description, frequency, context):
"""分析行为背后的可能原因"""
possible_patterns = []
for pattern_name, pattern in BEHAVIOR_PATTERNS.items():
# Check if behavior matches typical behaviors
behavior_match = any(b in behavior_description for b in pattern['typical_behaviors'])
context_match = any(c in context for c in pattern['triggers'])
if behavior_match or context_match:
confidence = 'high' if behavior_match and context_match else 'medium'
possible_patterns.append({
'pattern': pattern_name,
'description': pattern['description'],
'confidence': confidence,
'triggers': pattern['triggers'],
'suggested_response': pattern['positive_response'][:2]
})
return {
'behavior_analysis': {
'description': behavior_description,
'frequency': frequency,
'context': context
},
'possible_patterns': possible_patterns,
'recommended_techniques': POSITIVE_DISCIPLINE_TECHNIQUES[:2]
}
def get_positive_discipline_plan(self, pattern_name, child_age_months):
"""生成正向管教计划"""
pattern = BEHAVIOR_PATTERNS.get(pattern_name)
if not pattern:
return {'error': 'Pattern not found'}
# Age-appropriate adaptations
adaptations = []
if child_age_months < 24:
adaptations = ['使用更简单的语言', '更多示范和引导', '保持一致性']
elif child_age_months < 48:
adaptations = ['加入简单解释', '使用视觉提示', '提供有限选择']
else:
adaptations = ['可以讨论感受', '共同制定规则', '赋予更多责任']
return {
'pattern': pattern_name,
'description': pattern['description'],
'age_adaptations': adaptations,
'prevention_strategies': pattern.get('prevention_strategies', []),
'positive_responses': pattern['positive_response'],
'techniques_to_try': POSITIVE_DISCIPLINE_TECHNIQUES[:3],
'mistakes_to_avoid': pattern['mistakes_to_avoid']
}
FILE:engine/communication.py
"""
CommunicationEngine - Parent-Child Communication Guidance
"""
from typing import Dict, List
COMMUNICATION_DB = {
'tantrum': {
'scenario': '孩子发脾气、哭闹',
'techniques': [
{
'name': '共情式倾听',
'description': '先承认孩子的情绪,再处理行为',
'steps': ['蹲下与孩子平视', '用平静的语气说“我看到你很生气/难过”', '描述你观察到的“因为玩具坏了,所以你很伤心”', '等待孩子情绪平复后再讨论解决方案'],
'example_scripts': {
'effective': '“我知道你很想要这个玩具,现在坏了你很伤心。我们一起看看能不能修好它。”',
'ineffective': '“别哭了!再哭我就不理你了!”'
}
},
{
'name': '提供有限选择',
'description': '给孩子有限的选择权,增加控制感',
'steps': ['提供2-3个可接受的选择', '用简单清晰的语言表达', '尊重孩子的选择并执行'],
'example_scripts': {
'effective': '“你是想先穿袜子还是先穿鞋子?”',
'ineffective': '“快穿衣服!”'
}
}
],
'common_mistakes': ['与孩子争吵', '威胁惩罚', '忽视情绪'],
'prevention_strategies': ['建立日常规律', '提前预告变化', '确保孩子基本需求得到满足']
},
'refusal': {
'scenario': '孩子拒绝配合(如不收拾玩具、不吃饭)',
'techniques': [
{
'name': '游戏化引导',
'description': '将任务转化为游戏',
'steps': ['创造有趣的游戏场景', '加入计时或比赛元素', '夸张地表扬进步'],
'example_scripts': {
'effective': '“我们来比赛,看谁先把积木送回家!”',
'ineffective': '“快点收拾,不然没收玩具!”'
}
},
{
'name': '自然结果法',
'description': '让孩子体验行为的自然结果',
'steps': ['提前告知可能的后果', '允许孩子体验自然结果', '事后讨论学习'],
'example_scripts': {
'effective': '“如果不收拾玩具,明天可能就找不到它们了。”',
'ineffective': '“你不收拾我就全扔了!”'
}
}
],
'common_mistakes': ['权力斗争', '反复唠叨', '情绪化反应'],
'prevention_strategies': ['建立清晰的规则和惯例', '给予过渡时间', '提供适当的自主权']
},
'sharing': {
'scenario': '孩子不愿分享玩具',
'techniques': [
{
'name': '轮流计时法',
'description': '用计时器明确轮流时间',
'steps': ['设置计时器(如2分钟)', '明确宣布轮流规则', '严格执行计时'],
'example_scripts': {
'effective': '“我们用计时器,每人玩2分钟,时间到了就换人。”',
'ineffective': '“你是哥哥要让着弟弟!”'
}
},
{
'name': '特殊物品尊重',
'description': '允许孩子保留特别珍爱的物品',
'steps': ['提前和孩子商量哪些是“特别玩具”', '将这些玩具放在安全地方', '教孩子如何礼貌拒绝分享特别物品'],
'example_scripts': {
'effective': '“这个小熊是你的好朋友,你可以不分享。其他玩具我们轮流玩好吗?”',
'ineffective': '“小气鬼!什么都要自己玩!”'
}
}
],
'common_mistakes': ['强迫分享', '贴标签(小气)', '比较孩子'],
'prevention_strategies': ['提前准备足够的玩具', '示范分享行为', '表扬分享时刻']
},
'bedtime': {
'scenario': '睡前拖延、不肯睡觉',
'techniques': [
{
'name': '可视化睡前程序',
'description': '用图片展示睡前步骤',
'steps': ['制作睡前程序图表', '每完成一步贴贴纸', '保持程序一致性'],
'example_scripts': {
'effective': '“看,我们已经完成了刷牙、换睡衣,现在该讲故事了。”',
'ineffective': '“都几点了还不睡!”'
}
},
{
'name': '有限选择+计时器',
'description': '结合选择和计时减少拖延',
'steps': ['提供有限选择“你想听一个故事还是两个?”', '使用计时器明确时间限制', '温和坚定地执行'],
'example_scripts': {
'effective': '“计时器响了我们就关灯,你可以选择听一个长故事或两个短故事。”',
'ineffective': '“再不睡明天别想看电视!”'
}
}
],
'common_mistakes': ['情绪化威胁', '不断让步', '程序不一致'],
'prevention_strategies': ['建立固定的睡前程序', '确保白天充足活动', '创造舒适的睡眠环境']
}
}
class CommunicationEngine:
def __init__(self):
self.name = 'CommunicationEngine'
def get_guidance(self, scenario, child_age_months=None):
guidance = COMMUNICATION_DB.get(scenario)
if not guidance:
return {'error': 'Scenario not found'}
result = {
'scenario': guidance['scenario'],
'techniques': guidance['techniques'],
'common_mistakes': guidance['common_mistakes'],
'prevention_strategies': guidance['prevention_strategies']
}
# Age-specific adaptations
if child_age_months:
if child_age_months < 36: # Under 3 years
result['age_adaptation'] = '对于幼儿,语言要更简单,示范比说教更重要'
else: # 3+ years
result['age_adaptation'] = '对于学龄前儿童,可以加入更多解释和讨论'
return result
def generate_script(self, scenario, technique_name):
guidance = COMMUNICATION_DB.get(scenario)
if not guidance:
return {'error': 'Scenario not found'}
for technique in guidance['techniques']:
if technique['name'] == technique_name:
return {
'technique': technique,
'practice_exercises': [
'角色扮演:家长和孩子互换角色',
'录像回放:录下互动过程一起观看讨论',
'渐进练习:从简单场景开始逐渐增加难度'
]
}
return {'error': 'Technique not found'}
FILE:engine/milestones.py
"""
MilestoneEngine - Child Development Milestone Tracking
"""
from typing import Dict, List
DOMAINS = {
'gross-motor': '大运动',
'fine-motor': '精细动作',
'language': '语言',
'cognitive': '认知',
'social-emotional': '社会情感',
'adaptive': '适应能力'
}
MILESTONE_DB = {
(0, 3): {
'gross-motor': [
{'id': 'gm_0_3_1', 'description': '俯卧时能抬头', 'typical': ['抬头45度'], 'advanced': ['抬头90度']},
{'id': 'gm_0_3_2', 'description': '四肢活动良好', 'typical': ['四肢无规律舞动'], 'advanced': []}
],
'fine-motor': [
{'id': 'fm_0_3_1', 'description': '手握拳', 'typical': ['紧紧握拳'], 'advanced': ['能抓住拨浪鼓']}
],
'language': [
{'id': 'la_0_3_1', 'description': '发出喉音', 'typical': ['咕咕声、咯咯声'], 'advanced': ['能发元音a,o']}
],
'cognitive': [
{'id': 'cg_0_3_1', 'description': '追视移动物体', 'typical': ['眼睛跟随人脸'], 'advanced': ['追视红球180度']}
],
'social-emotional': [
{'id': 'se_0_3_1', 'description': '对声音有反应', 'typical': ['听到声音停止活动'], 'advanced': ['转头寻找声源']}
],
'adaptive': [
{'id': 'ad_0_3_1', 'description': '建立喂养和睡眠规律', 'typical': ['开始形成规律'], 'advanced': []}
]
},
(3, 6): {
'gross-motor': [
{'id': 'gm_3_6_1', 'description': '翻身', 'typical': ['从仰卧翻到俯卧'], 'advanced': ['从俯卧翻到仰卧']},
{'id': 'gm_3_6_2', 'description': '坐立', 'typical': ['撑手独坐片刻'], 'advanced': ['独坐较稳']}
],
'fine-motor': [
{'id': 'fm_3_6_1', 'description': '伸手抓握', 'typical': ['主动抓握玩具'], 'advanced': ['换手传递']}
],
'language': [
{'id': 'la_3_6_1', 'description': '发出音节', 'typical': ['ma,ba,da音节'], 'advanced': ['模仿声音']}
],
'cognitive': [
{'id': 'cg_3_6_1', 'description': '认识熟悉的人', 'typical': ['看到妈妈有反应'], 'advanced': ['主动寻求互动']}
],
'social-emotional': [
{'id': 'se_3_6_1', 'description': '开始认生', 'typical': ['对陌生人有反应'], 'advanced': ['明显依恋主要照护人']}
],
'adaptive': [
{'id': 'ad_3_6_1', 'description': '开始添加辅食', 'typical': ['接受勺喂'], 'advanced': ['能够自己拿食物']}
]
},
(6, 12): {
'gross-motor': [
{'id': 'gm_6_12_1', 'description': '爬行', 'typical': ['四肢爬行'], 'advanced': ['扶站扶走']},
{'id': 'gm_6_12_2', 'description': '站立和行走', 'typical': ['独站片刻'], 'advanced': ['独立行走几步']}
],
'fine-motor': [
{'id': 'fm_6_12_1', 'description': '捏取小物品', 'typical': ['拇指食指捏起'], 'advanced': ['叠积木2-3块']}
],
'language': [
{'id': 'la_6_12_1', 'description': '理解简单词汇', 'typical': ['听懂再见'], 'advanced': ['叫爸妈']}
],
'cognitive': [
{'id': 'cg_6_12_1', 'description': '客体永久性', 'typical': ['找藏起的玩具'], 'advanced': ['用工具够玩具']}
],
'social-emotional': [
{'id': 'se_6_12_1', 'description': '分离焦虑', 'typical': ['主要照护人离开时哭闹'], 'advanced': []}
],
'adaptive': [
{'id': 'ad_6_12_1', 'description': '自主进食意愿', 'typical': ['抢勺子'], 'advanced': ['用杯子喝水']}
]
},
(12, 24): {
'gross-motor': [
{'id': 'gm_12_24_1', 'description': '独立行走', 'typical': ['走得熟练'], 'advanced': ['能跑']},
{'id': 'gm_12_24_2', 'description': '攀爬', 'typical': ['爬上椅子'], 'advanced': ['双脚跳']}
],
'fine-motor': [
{'id': 'fm_12_24_1', 'description': '搭积木', 'typical': ['叠2-3块'], 'advanced': ['叠4-6块']}
],
'language': [
{'id': 'la_12_24_1', 'description': '说单词', 'typical': ['10-50个词汇'], 'advanced': ['说短句']}
],
'cognitive': [
{'id': 'cg_12_24_1', 'description': '假想游戏', 'typical': ['拿玩具电话假装打电话'], 'advanced': []}
],
'social-emotional': [
{'id': 'se_12_24_1', 'description': '同伴意识', 'typical': ['看其他孩子玩'], 'advanced': ['出现社交互动']}
],
'adaptive': [
{'id': 'ad_12_24_1', 'description': '使用勺子', 'typical': ['较熟练使用'], 'advanced': []}
]
},
(24, 36): {
'gross-motor': [
{'id': 'gm_24_36_1', 'description': '跑跳能力', 'typical': ['能跑但不稳'], 'advanced': ['双脚交替上下楼梯']},
{'id': 'gm_24_36_2', 'description': '大动作协调', 'typical': ['踢球'], 'advanced': ['骑三轮车']}
],
'fine-motor': [
{'id': 'fm_24_36_1', 'description': '精细动作发展', 'typical': ['用蜡笔涂鸦'], 'advanced': ['模仿画直线']}
],
'language': [
{'id': 'la_24_36_1', 'description': '语言爆发期', 'typical': ['词汇量快速增长'], 'advanced': ['说完整句子']}
],
'cognitive': [
{'id': 'cg_24_36_1', 'description': '平行游戏到合作游戏', 'typical': ['各玩各的'], 'advanced': ['开始简单合作']}
],
'social-emotional': [
{'id': 'se_24_36_1', 'description': '自我意识发展', 'typical': ['说'我''], 'advanced': ['用名字称呼自己']}
],
'adaptive': [
{'id': 'ad_24_36_1', 'description': '如厕训练准备', 'typical': ['白天能控制排尿'], 'advanced': ['能表达如厕需求']}
]
},
(36, 60): {
'gross-motor': [
{'id': 'gm_36_60_1', 'description': '平衡与协调', 'typical': ['单脚站1-2秒'], 'advanced': ['单脚站5秒以上']},
{'id': 'gm_36_60_2', 'description': '跳跃', 'typical': ['并脚跳'], 'advanced': ['单脚跳']}
],
'fine-motor': [
{'id': 'fm_36_60_1', 'description': '书写准备', 'typical': ['描摹图形'], 'advanced': ['写出简单字']}
],
'language': [
{'id': 'la_36_60_1', 'description': '语言表达', 'typical': ['说完整故事'], 'advanced': ['复述发生的事情']}
],
'cognitive': [
{'id': 'cg_36_60_1', 'description': '数感发展', 'typical': ['数数1-10'], 'advanced': ['理解数量关系']}
],
'social-emotional': [
{'id': 'se_36_60_1', 'description': '亲社会行为', 'typical': ['分享玩具'], 'advanced': ['安慰其他孩子']}
],
'adaptive': [
{'id': 'ad_36_60_1', 'description': '自理能力', 'typical': ['独立穿衣'], 'advanced': ['学会系扣子']}
]
}
}
RED_FLAG_THRESHOLDS = {
3: {'gross-motor': '不能抬头', 'language': '对声音无反应'},
6: {'gross-motor': '不能翻身', 'language': '不发出任何声音'},
12: {'gross-motor': '不能独坐', 'language': '不懂简单指令'},
24: {'gross-motor': '不能独立行走', 'language': '不会说单词'},
36: {'language': '不能说短句', 'cognitive': '不理解简单指令'},
60: {'language': '不能说完整句子', 'cognitive': '无法数数'}
}
class MilestoneEngine:
def __init__(self):
self.name = 'MilestoneEngine'
def get_age_range(self, age_months):
ranges = [(0, 3), (3, 6), (6, 12), (12, 24), (24, 36), (36, 60)]
for r in ranges:
if r[0] <= age_months < r[1]:
return r
return (36, 60)
def assess_milestones(self, age_months, observations=None):
age_range = self.get_age_range(age_months)
domain_data = MILESTONE_DB.get(age_range, MILESTONE_DB[(36, 60)])
achieved = []
upcoming = []
at_risk = []
for domain, milestones in domain_data.items():
for m in milestones:
status = 'typical'
if observations and domain in observations:
for tb in m.get('typical', []):
if tb in str(observations[domain]):
status = 'achieved'
achieved.append({'id': m['id'], 'domain': domain, 'domain_cn': DOMAINS.get(domain, domain), 'description': m['description'], 'status': status})
all_ranges = sorted(MILESTONE_DB.keys())
next_range = None
for r in all_ranges:
if r[0] == age_range[1]:
next_range = r
break
if next_range:
for m in MILESTONE_DB[next_range].get(domain, []):
upcoming.append({'id': m['id'], 'domain': domain, 'domain_cn': DOMAINS.get(domain, domain), 'description': m['description'], 'next_age_range': str(next_range[0]) + '-' + str(next_range[1]) + '个月'})
if age_months in RED_FLAG_THRESHOLDS:
for domain, flag_desc in RED_FLAG_THRESHOLDS[age_months].items():
if observations and domain in observations:
if not any(m['domain'] == domain and m['status'] == 'achieved' for m in achieved):
at_risk.append({'domain': domain, 'domain_cn': DOMAINS.get(domain, domain), 'warning': flag_desc, 'recommendation': '建议咨询专业人士'})
return {'age_months': age_months, 'age_range': str(age_range[0]) + '-' + str(age_range[1]) + '个月', 'total_milestones_checked': len(achieved), 'achieved': achieved, 'upcoming': upcoming[:6], 'red_flags': at_risk, 'summary': {'milestones_achieved_count': len([m for m in achieved if m.get('status') == 'achieved']), 'development_status': 'at_risk' if at_risk else 'on_track'}}
def generate_recommendations(self, assessment):
recommendations = []
if assessment['summary']['development_status'] == 'at_risk':
recommendations.append({'type': 'professional', 'priority': 'high', 'title': '建议寻求专业评估', 'description': '发现潜在发展预警信号,建议联系儿科医生或儿童发展专家'})
achieved_count = assessment['summary']['milestones_achieved_count']
if achieved_count < 3:
recommendations.append({'type': 'activity', 'priority': 'medium', 'title': '加强日常互动', 'description': '通过日常游戏和互动支持该年龄段的发展'})
for upcoming in assessment.get('upcoming', [])[:2]:
recommendations.append({'type': 'activity', 'priority': 'low', 'title': '预备' + upcoming['domain_cn'] + '发展', 'description': '下一阶段可关注:' + upcoming['description']})
return recommendations
FILE:engine/types.py
"""
Parenting Growth Partner - Core Types
Defines all data structures based on design document.
"""
from dataclasses import dataclass, field
from typing import List, Optional, Literal
# ---- Child Profile ----
@dataclass
class ChildBasicInfo:
name: str
birth_date: str
gender: Literal["male", "female"]
birth_weight: float # kg
birth_length: float # cm
gestational_age: int # weeks
@dataclass
class ChildMeasurements:
weight: float # kg
height: float # cm
head_circumference: Optional[float] = None
last_measured: Optional[str] = None
@dataclass
class ChildProfile:
id: str
basic_info: ChildBasicInfo
measurements: Optional[ChildMeasurements] = None
temperament: Literal["easy", "difficult", "slow-to-warm-up", "mixed"] = "mixed"
interests: List[str] = field(default_factory=list)
strengths: List[str] = field(default_factory=list)
challenges: List[str] = field(default_factory=list)
# ---- Milestone ----
@dataclass
class Milestone:
id: str
domain: Literal["gross-motor", "fine-motor", "language", "cognitive", "social-emotional", "adaptive"]
age_range: tuple[int, int] # (min_months, max_months)
description: str
behaviors: dict # {early, typical, advanced}
red_flags: dict # {absence, regression, extreme}
support_strategies: dict
# ---- Activity ----
@dataclass
class Activity:
id: str
name: str
description: str
age_range: tuple[int, int]
domains: List[str]
difficulty: Literal["easy", "medium", "hard"]
duration_minutes: int
materials: List[str]
steps: List[str]
safety_notes: List[str] = field(default_factory=list)
# ---- Communication ----
@dataclass
class CommunicationScript:
situation: str
effective_words: str
ineffective_words: str
rationale: str
@dataclass
class CommunicationTechnique:
name: str
description: str
when_to_use: str
scripts: List[CommunicationScript]
# ---- Behavior ----
@dataclass
class BehaviorAnalysis:
behavior: str
frequency: Literal["occasional", "frequent", "persistent"]
intensity: Literal["mild", "moderate", "severe"]
context: str
likely_function: Literal["attention-seeking", "escape-avoidance", "tangible", "sensory", "communication"]
causes: List[str]
preventive_strategies: List[str]
responsive_strategies: List[str]
teaching_steps: List[str]
FILE:handler.py
"""
Parenting Growth Partner - Main Handler
"""
import json
from typing import Dict, Any
from engine.milestones import MilestoneEngine
from engine.activities import ActivityEngine
from engine.communication import CommunicationEngine
from engine.behavior import BehaviorEngine
class ParentingGrowthPartner:
def __init__(self):
self.milestone_engine = MilestoneEngine()
self.activity_engine = ActivityEngine()
self.communication_engine = CommunicationEngine()
self.behavior_engine = BehaviorEngine()
def handle_milestone_assessment(self, age_months: int, observations: Dict = None) -> Dict:
"""处理发展里程碑评估"""
try:
assessment = self.milestone_engine.assess_milestones(age_months, observations)
recommendations = self.milestone_engine.generate_recommendations(assessment)
return {
'success': True,
'assessment': assessment,
'recommendations': recommendations,
'summary': {
'development_status': assessment['summary']['development_status'],
'milestones_checked': assessment['total_milestones_checked'],
'milestones_achieved': assessment['summary']['milestones_achieved_count']
}
}
except Exception as e:
return {'success': False, 'error': str(e)}
def handle_activity_recommendation(self, age_months: int, available_time: int = 30,
preferred_domains: list = None) -> Dict:
"""处理活动推荐"""
try:
recommendations = self.activity_engine.recommend_activities(
age_months, available_time, preferred_domains
)
return {
'success': True,
'recommendations': recommendations,
'summary': recommendations['summary']
}
except Exception as e:
return {'success': False, 'error': str(e)}
def handle_communication_guidance(self, scenario: str, child_age_months: int = None) -> Dict:
"""处理沟通指导"""
try:
guidance = self.communication_engine.get_guidance(scenario, child_age_months)
return {
'success': True,
'scenario': scenario,
'guidance': guidance,
'quick_tips': [
'保持冷静,先处理情绪再处理问题',
'蹲下与孩子平视交流',
'使用“我”语句表达感受'
]
}
except Exception as e:
return {'success': False, 'error': str(e)}
def handle_behavior_analysis(self, behavior_description: str, frequency: str,
context: str, child_age_months: int = None) -> Dict:
"""处理行为分析"""
try:
analysis = self.behavior_engine.analyze_behavior(behavior_description, frequency, context)
if child_age_months and analysis['possible_patterns']:
pattern_name = analysis['possible_patterns'][0]['pattern']
discipline_plan = self.behavior_engine.get_positive_discipline_plan(
pattern_name, child_age_months
)
analysis['discipline_plan'] = discipline_plan
return {
'success': True,
'analysis': analysis,
'next_steps': [
'尝试1-2个推荐技巧',
'观察一周记录变化',
'根据效果调整策略'
]
}
except Exception as e:
return {'success': False, 'error': str(e)}
def handle_daily_routine_suggestion(self, child_age_months: int) -> Dict:
"""处理日常作息建议"""
try:
# Age-based routine suggestions
if child_age_months < 12:
routine = {
'wake_up': '7:00-8:00',
'naps': '2-3次小睡',
'meals': '母乳/配方奶 + 辅食2-3次',
'active_play': '多次短时间活动',
'bedtime': '19:00-20:00'
}
tips = ['保持喂养和睡眠规律', '白天充分活动促进夜间睡眠']
elif child_age_months < 36:
routine = {
'wake_up': '7:00-8:00',
'naps': '1次午睡(2-3小时)',
'meals': '3餐 + 2次点心',
'active_play': '上午和下午各1小时',
'quiet_time': '午睡前30分钟',
'bedtime': '20:00-21:00'
}
tips = ['建立固定的睡前程序', '确保白天充足户外活动']
else:
routine = {
'wake_up': '7:00-8:00',
'meals': '3餐 + 1次点心',
'active_play': '至少2小时',
'learning_time': '30-60分钟',
'family_time': '晚餐后30分钟',
'bedtime': '20:30-21:30'
}
tips = ['让孩子参与制定作息表', '使用视觉提示帮助孩子理解']
return {
'success': True,
'age_months': child_age_months,
'suggested_routine': routine,
'tips': tips,
'flexibility_note': '根据孩子实际情况调整,保持大致规律即可'
}
except Exception as e:
return {'success': False, 'error': str(e)}
def handler(event, context):
"""Main handler function for OpenClaw skill"""
partner = ParentingGrowthPartner()
# Parse input
action = event.get('action', 'milestone_assessment')
params = event.get('params', {})
# Route to appropriate handler
if action == 'milestone_assessment':
age_months = params.get('age_months', 24)
observations = params.get('observations')
result = partner.handle_milestone_assessment(age_months, observations)
elif action == 'activity_recommendation':
age_months = params.get('age_months', 24)
available_time = params.get('available_time', 30)
preferred_domains = params.get('preferred_domains')
result = partner.handle_activity_recommendation(age_months, available_time, preferred_domains)
elif action == 'communication_guidance':
scenario = params.get('scenario', 'tantrum')
child_age_months = params.get('child_age_months')
result = partner.handle_communication_guidance(scenario, child_age_months)
elif action == 'behavior_analysis':
behavior_description = params.get('behavior_description', '')
frequency = params.get('frequency', 'occasional')
context = params.get('context', '')
child_age_months = params.get('child_age_months')
result = partner.handle_behavior_analysis(behavior_description, frequency, context, child_age_months)
elif action == 'daily_routine':
child_age_months = params.get('child_age_months', 24)
result = partner.handle_daily_routine_suggestion(child_age_months)
else:
result = {'success': False, 'error': f'Unknown action: {action}'}
return result
if __name__ == '__main__':
"""Self-test when run directly"""
print('=== Parenting Growth Partner Self-Test ===')
partner = ParentingGrowthPartner()
# Test 1: Milestone assessment
print('
1. Testing milestone assessment (24 months):')
result1 = partner.handle_milestone_assessment(24)
print(f' Success: {result1["success"]}')
print(f' Development status: {result1["assessment"]["summary"]["development_status"]}')
# Test 2: Activity recommendation
print('
2. Testing activity recommendation (24 months, 30 min):')
result2 = partner.handle_activity_recommendation(24, 30)
print(f' Success: {result2["success"]}')
print(f' Activities available: {result2["summary"]["total_available"]}')
# Test 3: Communication guidance
print('
3. Testing communication guidance (tantrum scenario):')
result3 = partner.handle_communication_guidance('tantrum', 24)
print(f' Success: {result3["success"]}')
print(f' Techniques: {len(result3["guidance"]["techniques"])}')
# Test 4: Behavior analysis
print('
4. Testing behavior analysis:')
result4 = partner.handle_behavior_analysis('经常说“不”,拖延', 'frequent', '被要求做事时', 24)
print(f' Success: {result4["success"]}')
print(f' Possible patterns: {len(result4["analysis"]["possible_patterns"])}')
# Test 5: Daily routine
print('
5. Testing daily routine suggestion (24 months):')
result5 = partner.handle_daily_routine_suggestion(24)
print(f' Success: {result5["success"]}')
print(f' Bedtime suggestion: {result5["suggested_routine"]["bedtime"]}')
print('
=== Self-test completed ===')
FILE:package.json
{
"name": "parenting-growth-partner",
"version": "0.1.0",
"description": "Parenting Growth Partner / 亲子成长伙伴 - 智能育儿支持系统",
"main": "handler.py",
"type": "module",
"scripts": {
"test": "python3 scripts/test-handler.py"
},
"keywords": ["parenting", "child-development", "education", "family"],
"author": "harrylabsj",
"license": "MIT"
}
FILE:scripts/test-handler.py
"""
Test script for Parenting Growth Partner handler
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from handler import ParentingGrowthPartner
def test_milestone_assessment():
"""Test milestone assessment functionality"""
print('=== Test 1: Milestone Assessment ===')
partner = ParentingGrowthPartner()
# Test with 24-month-old
result = partner.handle_milestone_assessment(24)
assert result['success'] == True
print(f'✓ 24-month assessment successful')
print(f' Development status: {result["assessment"]["summary"]["development_status"]}')
print(f' Milestones checked: {result["assessment"]["total_milestones_checked"]}')
# Test with observations
result = partner.handle_milestone_assessment(
18,
{'language': ['says mama', 'understands no'], 'gross-motor': ['walks well']}
)
assert result['success'] == True
print(f'✓ 18-month assessment with observations successful')
return True
def test_activity_recommendation():
"""Test activity recommendation functionality"""
print('
=== Test 2: Activity Recommendation ===')
partner = ParentingGrowthPartner()
# Basic recommendation
result = partner.handle_activity_recommendation(30, 20)
assert result['success'] == True
print(f'✓ Basic recommendation successful')
print(f' Activities available: {result["summary"]["total_available"]}')
# With preferred domains
result = partner.handle_activity_recommendation(
36, 30, ['fine-motor', 'cognitive']
)
assert result['success'] == True
print(f'✓ Recommendation with domains successful')
return True
def test_communication_guidance():
"""Test communication guidance functionality"""
print('
=== Test 3: Communication Guidance ===')
partner = ParentingGrowthPartner()
# Test tantrum scenario
result = partner.handle_communication_guidance('tantrum', 24)
assert result['success'] == True
print(f'✓ Tantrum guidance successful')
print(f' Techniques available: {len(result["guidance"]["techniques"])}')
# Test bedtime scenario
result = partner.handle_communication_guidance('bedtime')
assert result['success'] == True
print(f'✓ Bedtime guidance successful')
return True
def test_behavior_analysis():
"""Test behavior analysis functionality"""
print('
=== Test 4: Behavior Analysis ===')
partner = ParentingGrowthPartner()
# Test power struggle behavior
result = partner.handle_behavior_analysis(
'经常说"不",拖延',
'frequent',
'被要求做事时',
30
)
assert result['success'] == True
print(f'✓ Behavior analysis successful')
print(f' Possible patterns: {len(result["analysis"]["possible_patterns"])}')
return True
def test_daily_routine():
"""Test daily routine functionality"""
print('
=== Test 5: Daily Routine ===')
partner = ParentingGrowthPartner()
# Test for 24-month-old
result = partner.handle_daily_routine_suggestion(24)
assert result['success'] == True
print(f'✓ Daily routine successful')
print(f' Bedtime suggestion: {result["suggested_routine"]["bedtime"]}')
# Test for infant
result = partner.handle_daily_routine_suggestion(6)
assert result['success'] == True
print(f'✓ Infant routine successful')
return True
def run_all_tests():
"""Run all tests"""
print('Running Parenting Growth Partner tests...')
print('=' * 50)
tests = [
test_milestone_assessment,
test_activity_recommendation,
test_communication_guidance,
test_behavior_analysis,
test_daily_routine
]
passed = 0
failed = 0
for test_func in tests:
try:
if test_func():
passed += 1
except Exception as e:
print(f'✗ {test_func.__name__} failed: {e}')
failed += 1
print('
' + '=' * 50)
print(f'Test Results: {passed} passed, {failed} failed')
if failed == 0:
print('✓ All tests passed!')
return True
else:
print('✗ Some tests failed')
return False
if __name__ == '__main__':
success = run_all_tests()
sys.exit(0 if success else 1)
FILE:skill.json
{
"name": "parenting-growth-partner",
"slug": "parenting-growth-partner",
"version": "1.0.0",
"description": "Parenting Growth Partner / 育儿成长伙伴 - AI-powered companion for evidence-based parenting guidance",
"author": "Harry",
"license": "MIT",
"handler": "handler.py",
"runtime": "python3",
"tags": [
"parenting",
"child-development",
"education",
"family"
],
"categories": [
"lifestyle",
"education"
],
"inputs": [
{
"name": "action",
"type": "string",
"required": true,
"description": "Action to perform: milestone_assessment, activity_recommendation, communication_guidance, behavior_analysis, daily_routine"
},
{
"name": "params",
"type": "object",
"required": false,
"description": "Parameters for the action"
}
],
"outputs": [
{
"name": "success",
"type": "boolean",
"description": "Whether the operation succeeded"
},
{
"name": "result",
"type": "object",
"description": "The result data structure"
}
]
}
FILE:test_simple.py
#!/usr/bin/env python3
"""Simple test for parenting-growth-partner"""
import sys
sys.path.insert(0, '.')
from handler import ParentingGrowthPartner
print('Testing Parenting Growth Partner...')
partner = ParentingGrowthPartner()
# Test 1
print('
1. Milestone assessment (24 months):')
result = partner.handle_milestone_assessment(24)
print(f' Success: {result["success"]}')
print(f' Status: {result["assessment"]["summary"]["development_status"]}')
# Test 2
print('
2. Activity recommendation:')
result = partner.handle_activity_recommendation(30, 20)
print(f' Success: {result["success"]}')
print(f' Activities: {result["summary"]["total_available"]}')
print('
✓ Basic functionality works!')
Family Finance Health Manager / 家庭财务健康管家. Provides comprehensive family financial management including income/expense analysis, savings goal breakdown, insur...
---
name: family-finance-manager
slug: family-finance-manager
version: 0.1.0
description: |
Family Finance Health Manager / 家庭财务健康管家.
Provides comprehensive family financial management including income/expense analysis,
savings goal breakdown, insurance recommendations, and financial health reporting.
---
# Family Finance Manager / 家庭财务健康管家
你是**家庭财务健康管家**。
你的任务是根据家庭财务数据,提供全面的财务分析、健康评估和个性化建议,帮助家庭建立健康的财务习惯,实现财务目标。
## 产品定位
Family Finance Manager 是一个全面的家庭财务管理工具,覆盖:
- **收支结构分析** - 分析收入来源和支出结构,计算储蓄率
- **储蓄目标拆解** - 将长期目标分解为可执行的月度计划
- **保险配置建议** - 根据家庭情况推荐合适的保险配置
- **财务风险预警** - 监测家庭财务风险,提前预警
- **财务健康报告** - 综合评估家庭财务状况
## 使用场景
用户可能会说:
- "帮我分析一下家庭的收支结构"
- "我想5年内存够100万,怎么规划"
- "我们家需要配置什么保险"
- "评估一下我们的财务健康状况"
- "每月存多少钱才能在退休时攒够养老金"
## 输入 schema(统一需求格式)
```typescript
interface FamilyFinanceRequest {
action: "analyze" | "goal-plan" | "insurance" | "risk-warning" | "health-report";
family?: {
name?: string;
members: FamilyMember[];
monthlyIncome: number;
annualIncome: number;
incomeStability: "high" | "medium" | "low";
};
assets?: {
liquid: number;
investments: number;
property: number;
other: number;
};
liabilities?: {
mortgage: number;
loans: number;
creditCards: number;
};
monthlyExpenses?: {
housing: number;
transportation: number;
food: number;
healthcare: number;
education: number;
entertainment: number;
other: number;
};
goals?: FinancialGoal[];
insurance?: {
life: number;
health: number;
property: number;
};
riskProfile?: "conservative" | "moderate" | "aggressive";
}
interface FamilyMember {
name: string;
age: number;
role: "self" | "spouse" | "child" | "parent";
income?: number;
}
interface FinancialGoal {
name: string;
amount: number;
years: number;
priority?: "high" | "medium" | "low";
}
```
## 输出 schema(统一财务报告)
```typescript
interface FinancialAnalysisReport {
incomeExpense: {
monthlyIncome: number;
monthlyExpenses: number;
monthlySavings: number;
savingsRate: number;
expenseBreakdown: Record<string, number>;
recommendations: string[];
};
netWorth: {
totalAssets: number;
totalLiabilities: number;
netWorth: number;
assetsComposition: Record<string, number>;
};
ratios: {
debtToIncome: number;
emergencyFundMonths: number;
investmentRatio: number;
};
suggestions: string[];
}
interface SavingsGoalPlan {
goal: FinancialGoal;
monthlyRequired: number;
yearlyRequired: number;
currentProgress: number;
completionPercentage: number;
milestones: { month: number; amount: number; description: string; }[];
investmentAdvice: string[];
riskAssessment: string;
}
interface InsuranceRecommendation {
coverageGaps: { life: number; health: number; disability: number; criticalIllness: number; };
recommendations: { type: string; priority: "high" | "medium" | "low"; reason: string; estimatedPremium?: number; }[];
totalRecommendedCoverage: number;
budgetConsiderations: string[];
}
interface RiskWarningReport {
overallRiskLevel: "low" | "medium" | "high" | "critical";
riskFactors: { factor: string; level: "low" | "medium" | "high"; description: string; mitigation: string; }[];
immediateActions: string[];
warningSigns: string[];
}
interface FinancialHealthReport {
overallScore: number;
scoreGrade: "excellent" | "good" | "fair" | "poor";
dimensions: { budgeting: number; saving: number; investing: number; debt: number; protection: number; planning: number; };
summary: string;
topStrengths: string[];
topConcerns: string[];
actionPlan: { priority: number; action: string; timeline: string; }[];
}
```
## 核心计算规则
### 储蓄率
储蓄率 = (月收入 - 月支出) / 月收入 × 100%
理想储蓄率: 20%+,优秀储蓄率: 40%+
### 紧急备用金
紧急备用金月数 = 流动资产 / 月支出
建议: 3-6个月
### 负债收入比
负债收入比 = 月负债还款 / 月收入
健康范围: <36%,警告范围: 36%-50%,危险范围: >50%
### 保险缺口
寿险缺口 = 家庭所需保额 - 现有保额
所需保额 = 年收入 × 覆盖年数 - 现有储蓄 - 现有保险
## 当前状态 (v0.1.0)
**MVP 骨架版本** - 所有分析功能为 stub 实现,基于规则计算返回模拟数据。
### 已实现
- ✅ 输入/输出 schema 定义
- ✅ 收支分析引擎
- ✅ 储蓄目标拆解引擎
- ✅ 保险配置建议引擎
- ✅ 风险预警引擎
- ✅ 财务健康评分引擎
- ✅ 自测脚本
### 待实现
- 🔄 接入真实家庭财务数据
- 🔄 历史数据分析
- 🔄 多周期趋势分析
- 🔄 真实的保险产品推荐
- 🔄 投资组合分析
## 目录结构
```
family-finance-manager/
├── SKILL.md # 技能定义
├── handler.py # 主逻辑入口
├── package.json # 依赖配置
├── clawhub.json # 技能元数据
├── engine/ # 决策引擎
│ ├── router.py # 路由层
│ └── types.py # 类型定义
└── scripts/ # 工具脚本
└── test_handler.py # 自测脚本
```
## 自测方法
```bash
cd ~/.openclaw/skills/family-finance-manager
python scripts/test_handler.py
```
## 相关 Skill
- `budget-manager` - 预算管理
- `bill-manager` - 账单管理
- `health-manager` - 健康管理
FILE:clawhub.json
{
"name": "family-finance-manager",
"version": "0.1.0",
"description": "家庭财务健康管家 - 全面家庭财务管理工具,包括收支分析、储蓄目标拆解、保险配置建议、财务风险预警和财务健康报告",
"keywords": ["family-finance", "financial-planning", "budget", "savings", "insurance", "investment"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/family-finance-manager",
"language": ["en", "zh"],
"tags": ["finance", "family", "budget", "savings", "insurance"]
}
FILE:engine/router.py
"""Family Finance Manager - Routing Engine"""
from engine.types import (FamilyFinanceRequest, FinancialAnalysisReport, SavingsGoalPlan,
InsuranceRecommendation, RiskWarningReport, FinancialHealthReport)
def run_family_finance_engine(request: FamilyFinanceRequest) -> dict:
action = request.action
if action == "analyze": return analyze_financial_health(request)
elif action == "goal-plan": return plan_savings_goal(request)
elif action == "insurance": return recommend_insurance(request)
elif action == "risk-warning": return generate_risk_warning(request)
elif action == "health-report": return generate_health_report(request)
else: return {"error": f"Unknown action: {action}"}
def analyze_financial_health(request):
report = FinancialAnalysisReport()
mi = request.monthly_income
me = request.get_total_monthly_expenses()
ms = mi - me
sr = (ms / mi * 100) if mi > 0 else 0
report.income_expense = {"monthlyIncome": mi, "monthlyExpenses": me, "monthlySavings": ms,
"savingsRate": round(sr, 2), "expenseBreakdown": request.monthly_expenses.copy(),
"recommendations": _exp_rec(sr, request.monthly_expenses)}
ta = request.get_total_assets(); tl = request.get_total_liabilities()
report.net_worth = {"totalAssets": ta, "totalLiabilities": tl, "netWorth": ta-tl,
"assetsComposition": {"liquid": request.assets.get("liquid",0), "investments": request.assets.get("investments",0), "property": request.assets.get("property",0), "other": request.assets.get("other",0)}}
efm = (request.assets.get("liquid",0)/me) if me>0 else 0
ir = (request.assets.get("investments",0)/ta*100) if ta>0 else 0
mdp = tl * 0.01
dti = (mdp/mi*100) if mi>0 else 0
report.ratios = {"debtToIncome": round(dti,2), "emergencyFundMonths": round(efm,1), "investmentRatio": round(ir,2)}
report.suggestions = _gen_sug(request, sr, dti, efm)
return report.to_dict()
def plan_savings_goal(request):
if not request.goals: return {"error": "No financial goal provided"}
goal = request.goals[0]; plan = SavingsGoalPlan(goal)
cs = request.assets.get("liquid",0) + request.assets.get("investments",0)
ar = 0.05; mr = ar/12; nm = goal.years * 12
if mr>0: mreq = goal.amount * mr / ((1+mr)**nm - 1)
else: mreq = goal.amount / nm
plan.monthly_required = round(mreq,2); plan.yearly_required = round(mreq*12,2)
plan.current_progress = cs; plan.completion_percentage = round((cs/goal.amount)*100,2) if goal.amount>0 else 0
for i in [1,2,3,5]:
if i <= goal.years:
months = i*12; ma = mreq*months+cs
plan.milestones.append({"month": months, "amount": round(ma,2), "description": f"第{i}年里程碑:积累至{ma/10000:.1f}万元"})
risk = request.risk_profile
if risk=="conservative": plan.investment_advice=["建议配置低风险理财产品,如定期存款、国债","银行理财产品的预期年化收益约3-4%","保持50%以上的低风险资产配置"]
elif risk=="moderate": plan.investment_advice=["建议股债平衡配置,股票类50%,债券类50%","可考虑定投指数基金,分散市场风险","预期年化收益约5-7%"]
else: plan.investment_advice=["可适当提高权益类资产配置至70-80%","考虑分散投资A股、港股、美股市场","预期年化收益约7-10%,但波动较大"]
mi = request.monthly_income; me = request.get_total_monthly_expenses(); ms = mi - me
if mreq <= ms*0.5: plan.risk_assessment = f"目标可实现,风险较低。当前每月可储蓄{ms:.0f}元,目标每月需{mreq:.0f}元,占储蓄能力的{mreq/ms*100:.0f}%。"
elif mreq <= ms*0.8: plan.risk_assessment = "目标实现有一定压力,需要严格执行储蓄计划。建议调整支出或延长目标时间。"
else: plan.risk_assessment = "目标实现难度较大,建议降低目标金额或延长实现时间,或增加收入来源。"
return plan.to_dict()
def recommend_insurance(request):
rec = InsuranceRecommendation()
ai = request.family.get("annualIncome", request.monthly_income*12)
el = request.insurance.get("life",0); eh = request.insurance.get("health",0)
rl = ai*10; rec.coverage_gaps["life"] = max(0, rl-el)
rh = ai*3; rec.coverage_gaps["health"] = max(0, rh-eh)
rd = ai*0.6; rec.coverage_gaps["disability"] = rd
rc = ai*1.5; rec.coverage_gaps["criticalIllness"] = rc
rec.total_recommended_coverage = rec.coverage_gaps["life"]+rec.coverage_gaps["health"]+rec.coverage_gaps["disability"]+rec.coverage_gaps["criticalIllness"]
if rec.coverage_gaps["life"]>0: rec.recommendations.append({"type":"定期寿险","priority":"high","reason":f"家庭经济支柱需要充足的寿险保障,建议保额覆盖{rl/10000:.0f}万元","estimatedPremium": round(rec.coverage_gaps["life"]*0.001,0)})
if rec.coverage_gaps["health"]>0: rec.recommendations.append({"type":"百万医疗险","priority":"high","reason":"补充社保不足,覆盖高额医疗费用","estimatedPremium": 300})
if rec.coverage_gaps["criticalIllness"]>ai*0.5: rec.recommendations.append({"type":"重疾险","priority":"medium","reason":"重大疾病可能导致收入中断,需要重疾保障","estimatedPremium": round(rec.coverage_gaps["criticalIllness"]*0.03,0)})
for m in request.members:
if m.role in ["self","spouse"] and m.age<50: rec.recommendations.append({"type":f"{m.name}的意外险","priority":"medium","reason":"家庭主要收入来源,建议配置意外险","estimatedPremium": 150})
rec.budget_considerations=["保险预算建议为年收入的5-10%","优先配置保障型保险,再考虑储蓄型","定期检视保险配置,随家庭情况变化调整"]
return rec.to_dict()
def generate_risk_warning(request):
report = RiskWarningReport()
mi = request.monthly_income; me = request.get_total_monthly_expenses(); ms = mi-me
tl = request.get_total_liabilities(); la = request.assets.get("liquid",0)
if mi>0:
sr = ms/mi
if sr<0.1: report.risk_factors.append({"factor":"储蓄率过低","level":"high","description":f"当前储蓄率仅{sr*100:.0f}%,低于10%,财务积累能力不足","mitigation":"建议控制非必要支出,将储蓄率提升至20%以上"})
elif sr<0.2: report.risk_factors.append({"factor":"储蓄率偏低","level":"medium","description":f"当前储蓄率{sr*100:.0f}%,有提升空间","mitigation":"可通过优化支出结构提高储蓄率"})
if me>0:
efm = la/me
if efm<3: report.risk_factors.append({"factor":"紧急备用金不足","level":"high","description":f"当前紧急备用金仅够{efm:.1f}个月,低于3个月标准","mitigation":"建议优先建立3-6个月的紧急备用金"})
elif efm<6: report.risk_factors.append({"factor":"紧急备用金偏低","level":"medium","description":f"当前紧急备用金约{efm:.1f}个月,建议提升至6个月","mitigation":"逐步增加流动资产储备"})
if mi>0 and tl>0:
md = tl*0.01; dr = md/mi
if dr>0.5: report.risk_factors.append({"factor":"负债率过高","level":"critical","description":f"负债收入比{dr*100:.0f}%,超过50%危险线","mitigation":"立即制定债务还款计划,优先偿还高息债务"})
elif dr>0.36: report.risk_factors.append({"factor":"负债率偏高","level":"high","description":f"负债收入比{dr*100:.0f}%,处于警戒区间","mitigation":"注意债务管理,避免进一步增加负债"})
er = me/mi if mi>0 else 1
if er>0.9: report.risk_factors.append({"factor":"支出占比过高","level":"high","description":f"支出占收入{er*100:.0f}%,几乎没有储蓄空间","mitigation":"必须审视并削减非必要支出"})
hc = sum(1 for rf in report.risk_factors if rf["level"] in ["high","critical"])
if hc>=3: report.overall_risk_level="critical"
elif hc>=2: report.overall_risk_level="high"
elif hc>=1: report.overall_risk_level="medium"
else: report.overall_risk_level="low"
if report.overall_risk_level in ["high","critical"]: report.immediate_actions.extend(["立即建立或扩充紧急备用金","制定月度预算并严格执行","减少非必要支出","评估并优化债务结构"])
report.warning_signs=["信用卡经常只还最低还款额" if tl>0 else "无信用卡负债","月光族,没有固定储蓄","不知道每月具体花销","没有购买任何保险"]
return report.to_dict()
def generate_health_report(request):
report = FinancialHealthReport()
mi = request.monthly_income; me = request.get_total_monthly_expenses(); ms = mi-me
ta = request.get_total_assets(); tl = request.get_total_liabilities(); ia = request.assets.get("investments",0)
if mi>0:
er = me/mi
if er<=0.5: report.dimensions["budgeting"]=100
elif er<=0.7: report.dimensions["budgeting"]=80
elif er<=0.85: report.dimensions["budgeting"]=60
elif er<=0.95: report.dimensions["budgeting"]=40
else: report.dimensions["budgeting"]=20
else: report.dimensions["budgeting"]=0
if mi>0:
sr = ms/mi; report.dimensions["saving"]=min(100,sr*200)
else: report.dimensions["saving"]=0
if ta>0:
ir = ia/ta; report.dimensions["investing"]=min(100,ir*150)
else: report.dimensions["investing"]=0
if mi>0:
md = tl*0.01; dr = md/mi
if dr<=0.2: report.dimensions["debt"]=100
elif dr<=0.36: report.dimensions["debt"]=80
elif dr<=0.5: report.dimensions["debt"]=50
else: report.dimensions["debt"]=20
else: report.dimensions["debt"]=50
ei = request.insurance; ins=0
if ei.get("life",0)>0: ins+=33
if ei.get("health",0)>0: ins+=33
if ei.get("property",0)>0: ins+=34
report.dimensions["protection"]=ins
ps=50
if request.goals: ps+=25
if request.risk_profile: ps+=25
report.dimensions["planning"]=ps
w={"budgeting":0.2,"saving":0.2,"investing":0.15,"debt":0.2,"protection":0.15,"planning":0.1}
report.overall_score=sum(report.dimensions[k]*w[k] for k in w)
if report.overall_score>=85: report.score_grade="excellent"
elif report.overall_score>=70: report.score_grade="good"
elif report.overall_score>=50: report.score_grade="fair"
else: report.score_grade="poor"
report.summary=f"您的家庭财务健康评分{report.overall_score:.0f}分({report.score_grade})。月收入{mi:.0f}元,月支出{me:.0f}元,月储蓄{ms:.0f}元,净资产{ta-tl:.0f}元。"
td=sorted(report.dimensions.items(),key=lambda x:x[1],reverse=True)[:3]
sm={"budgeting":"预算管理","saving":"储蓄能力","investing":"投资配置","debt":"债务管理","protection":"风险保障","planning":"财务规划"}
report.top_strengths=[f"{sm[k]}较强({v:.0f}分)" for k,v in td if v>=70]
report.top_concerns=[f"{sm[k]}需要改善({v:.0f}分)" for k,v in td if v<60]
concerns=[(k,v) for k,v in report.dimensions.items() if v<70]; concerns.sort(key=lambda x:x[1])
da={"budgeting":("控制不必要支出,建议使用记账App追踪开销","1个月内"),"saving":("提高储蓄率至20%以上,先储后花","3个月内"),"investing":("根据风险偏好配置多元化投资组合","6个月内"),"debt":("制定债务还款计划,优先偿还高息债务","立即"),"protection":("补充必要的保险保障","3个月内"),"planning":("设定明确的财务目标并制定执行计划","1个月内")}
for i,(dim,score) in enumerate(concerns[:3]):
a,t=da.get(dim,("改善财务状况","尽快")); report.action_plan.append({"priority":i+1,"action":a,"timeline":t})
return report.to_dict()
def _exp_rec(sr, expenses):
recs=[]; total=sum(expenses.values()) if expenses else 0
if expenses.get("entertainment",0)>total*0.1: recs.append("娱乐支出占比偏高,建议控制在10%以内")
if expenses.get("food",0)>total*0.3: recs.append("餐饮支出较高,可考虑减少外卖,增加在家做饭")
if sr<20: recs.append("储蓄率偏低,建议采用'先储后花'策略")
if not recs: recs.append("支出结构基本合理,继续保持")
return recs
def _gen_sug(request, sr, dti, efm):
sug=[]
if sr<20: sug.append("建议提高储蓄率至20%以上,为未来积累财富")
if efm<3: sug.append("紧急备用金不足,建议储备3-6个月生活费")
if dti>50: sug.append("负债率过高,建议优先偿还高息债务")
if request.assets.get("investments",0)==0: sug.append("建议开始投资,通过多元化配置实现财富增值")
if request.insurance.get("life",0)==0 and request.insurance.get("health",0)==0: sug.append("建议配置基础保险,转移家庭财务风险")
if not sug: sug.append("财务状况良好,继续保持当前的理财习惯")
return sug
FILE:engine/types.py
"""Family Finance Manager - Type Definitions"""
from typing import Optional, List, Literal
# 操作类型
ActionType = Literal["analyze", "goal-plan", "insurance", "risk-warning", "health-report"]
# 收入稳定性
IncomeStability = Literal["high", "medium", "low"]
# 风险偏好
RiskProfile = Literal["conservative", "moderate", "aggressive"]
# 财务等级
ScoreGrade = Literal["excellent", "good", "fair", "poor"]
# 风险级别
RiskLevel = Literal["low", "medium", "high", "critical"]
class FamilyMember:
def __init__(self, name: str, age: int, role: str, income: float = 0):
self.name = name
self.age = age
self.role = role # "self", "spouse", "child", "parent"
self.income = income
class FinancialGoal:
def __init__(self, name: str, amount: float, years: int, priority: str = "medium"):
self.name = name
self.amount = amount
self.years = years
self.priority = priority # "high", "medium", "low"
class FamilyFinanceRequest:
def __init__(self, data: dict):
self.action = data.get("action", "analyze")
self.family = data.get("family", {})
self.assets = data.get("assets", {})
self.liabilities = data.get("liabilities", {})
self.monthly_expenses = data.get("monthlyExpenses", {})
self.goals = data.get("goals", [])
self.insurance = data.get("insurance", {})
self.risk_profile = data.get("riskProfile", "moderate")
# Parse family members
self.members = [
FamilyMember(
name=m.get("name", ""),
age=m.get("age", 0),
role=m.get("role", "self"),
income=m.get("income", 0)
)
for m in self.family.get("members", [])
]
# Parse financial goals
self.goals = [
FinancialGoal(
name=g.get("name", ""),
amount=g.get("amount", 0),
years=g.get("years", 1),
priority=g.get("priority", "medium")
)
for g in data.get("goals", [])
]
@property
def monthly_income(self) -> float:
return self.family.get("monthlyIncome", 0)
@property
def income_stability(self) -> IncomeStability:
return self.family.get("incomeStability", "medium")
def get_total_assets(self) -> float:
return (
self.assets.get("liquid", 0) +
self.assets.get("investments", 0) +
self.assets.get("property", 0) +
self.assets.get("other", 0)
)
def get_total_liabilities(self) -> float:
return (
self.liabilities.get("mortgage", 0) +
self.liabilities.get("loans", 0) +
self.liabilities.get("creditCards", 0)
)
def get_total_monthly_expenses(self) -> float:
return sum(self.monthly_expenses.values())
def get_net_worth(self) -> float:
return self.get_total_assets() - self.get_total_liabilities()
class FinancialAnalysisReport:
def __init__(self):
self.income_expense = {}
self.net_worth = {}
self.ratios = {}
self.suggestions = []
def to_dict(self) -> dict:
return {
"incomeExpense": self.income_expense,
"netWorth": self.net_worth,
"ratios": self.ratios,
"suggestions": self.suggestions
}
class SavingsGoalPlan:
def __init__(self, goal: FinancialGoal):
self.goal = goal
self.monthly_required = 0.0
self.yearly_required = 0.0
self.current_progress = 0.0
self.completion_percentage = 0.0
self.milestones = []
self.investment_advice = []
self.risk_assessment = ""
def to_dict(self) -> dict:
return {
"goal": {
"name": self.goal.name,
"amount": self.goal.amount,
"years": self.goal.years,
"priority": self.goal.priority
},
"monthlyRequired": self.monthly_required,
"yearlyRequired": self.yearly_required,
"currentProgress": self.current_progress,
"completionPercentage": self.completion_percentage,
"milestones": self.milestones,
"investmentAdvice": self.investment_advice,
"riskAssessment": self.risk_assessment
}
class InsuranceRecommendation:
def __init__(self):
self.coverage_gaps = {"life": 0, "health": 0, "disability": 0, "criticalIllness": 0}
self.recommendations = []
self.total_recommended_coverage = 0
self.budget_considerations = []
def to_dict(self) -> dict:
return {
"coverageGaps": self.coverage_gaps,
"recommendations": self.recommendations,
"totalRecommendedCoverage": self.total_recommended_coverage,
"budgetConsiderations": self.budget_considerations
}
class RiskWarningReport:
def __init__(self):
self.overall_risk_level: RiskLevel = "low"
self.risk_factors = []
self.immediate_actions = []
self.warning_signs = []
def to_dict(self) -> dict:
return {
"overallRiskLevel": self.overall_risk_level,
"riskFactors": self.risk_factors,
"immediateActions": self.immediate_actions,
"warningSigns": self.warning_signs
}
class FinancialHealthReport:
def __init__(self):
self.overall_score = 0
self.score_grade: ScoreGrade = "fair"
self.dimensions = {
"budgeting": 0,
"saving": 0,
"investing": 0,
"debt": 0,
"protection": 0,
"planning": 0
}
self.summary = ""
self.top_strengths = []
self.top_concerns = []
self.action_plan = []
def to_dict(self) -> dict:
return {
"overallScore": self.overall_score,
"scoreGrade": self.score_grade,
"dimensions": self.dimensions,
"summary": self.summary,
"topStrengths": self.top_strengths,
"topConcerns": self.top_concerns,
"actionPlan": self.action_plan
}
FILE:handler.py
"""Family Finance Manager - Main Handler"""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from engine.router import run_family_finance_engine
from engine.types import FamilyFinanceRequest
def handle(request_data: dict) -> dict:
"""Main entry point for handling family finance requests"""
try:
request = FamilyFinanceRequest(request_data)
result = run_family_finance_engine(request)
return {"success": True, "data": result}
except Exception as e:
return {"success": False, "error": str(e)}
def main():
"""CLI entry point for testing"""
import json
# Sample request
sample_request = {
"action": "health-report",
"family": {
"name": "示例家庭",
"members": [
{"name": "爸爸", "age": 40, "role": "self", "income": 30000},
{"name": "妈妈", "age": 38, "role": "spouse", "income": 20000}
],
"monthlyIncome": 50000,
"annualIncome": 600000,
"incomeStability": "high"
},
"assets": {
"liquid": 100000,
"investments": 200000,
"property": 3000000,
"other": 100000
},
"liabilities": {
"mortgage": 1500000,
"loans": 100000,
"creditCards": 20000
},
"monthlyExpenses": {
"housing": 8000,
"transportation": 3000,
"food": 6000,
"healthcare": 2000,
"education": 3000,
"entertainment": 2000,
"other": 3000
},
"insurance": {
"life": 500000,
"health": 300000,
"property": 1000000
},
"riskProfile": "moderate",
"goals": [
{"name": "子女教育金", "amount": 1000000, "years": 15, "priority": "high"},
{"name": "退休金", "amount": 5000000, "years": 20, "priority": "medium"}
]
}
result = handle(sample_request)
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:package.json
{
"name": "family-finance-manager",
"version": "0.1.0",
"description": "家庭财务健康管家 - 全面家庭财务管理工具",
"keywords": ["family-finance", "financial-planning", "budget", "savings", "insurance", "investment"],
"author": "harrylabsj",
"license": "MIT",
"language": ["en", "zh"],
"tags": ["finance", "family", "budget", "savings", "insurance"],
"createdAt": "2026-04-05",
"updatedAt": "2026-04-05"
}
FILE:scripts/test_handler.py
#!/usr/bin/env python3
"""Test script for Family Finance Manager"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from handler import handle
def test_health_report():
"""Test health report generation"""
request = {
"action": "health-report",
"family": {
"members": [{"name": "爸爸", "age": 40, "role": "self", "income": 30000}],
"monthlyIncome": 50000,
"annualIncome": 600000,
"incomeStability": "high"
},
"assets": {"liquid": 100000, "investments": 200000, "property": 3000000, "other": 100000},
"liabilities": {"mortgage": 1500000, "loans": 100000, "creditCards": 20000},
"monthlyExpenses": {"housing": 8000, "transportation": 3000, "food": 6000, "healthcare": 2000, "education": 3000, "entertainment": 2000, "other": 3000},
"insurance": {"life": 500000, "health": 300000, "property": 1000000},
"riskProfile": "moderate",
"goals": [{"name": "子女教育金", "amount": 1000000, "years": 15, "priority": "high"}]
}
result = handle(request)
assert result["success"] == True
data = result["data"]
assert "overallScore" in data
assert "dimensions" in data
print(f"✓ health-report: score={data['overallScore']}, grade={data['scoreGrade']}")
return data
def test_analyze():
"""Test financial analysis"""
request = {
"action": "analyze",
"family": {"monthlyIncome": 50000, "annualIncome": 600000, "incomeStability": "high", "members": []},
"assets": {"liquid": 100000, "investments": 200000, "property": 3000000, "other": 100000},
"liabilities": {"mortgage": 1500000, "loans": 100000, "creditCards": 20000},
"monthlyExpenses": {"housing": 8000, "transportation": 3000, "food": 6000, "healthcare": 2000, "education": 3000, "entertainment": 2000, "other": 3000}
}
result = handle(request)
assert result["success"] == True
data = result["data"]
assert "incomeExpense" in data
assert "netWorth" in data
print(f"✓ analyze: savingsRate={data['incomeExpense']['savingsRate']}%, netWorth={data['netWorth']['netWorth']}")
return data
def test_goal_plan():
"""Test savings goal planning"""
request = {
"action": "goal-plan",
"family": {"monthlyIncome": 50000, "annualIncome": 600000, "members": []},
"assets": {"liquid": 100000, "investments": 200000, "property": 0, "other": 0},
"monthlyExpenses": {"housing": 8000, "transportation": 3000, "food": 6000, "healthcare": 2000, "education": 3000, "entertainment": 2000, "other": 3000},
"riskProfile": "moderate",
"goals": [{"name": "购房首付款", "amount": 1000000, "years": 5, "priority": "high"}]
}
result = handle(request)
assert result["success"] == True
data = result["data"]
assert "monthlyRequired" in data
assert "milestones" in data
print(f"✓ goal-plan: monthlyRequired={data['monthlyRequired']}, milestones={len(data['milestones'])}")
return data
def test_insurance():
"""Test insurance recommendations"""
request = {
"action": "insurance",
"family": {"monthlyIncome": 50000, "annualIncome": 600000, "members": [{"name": "爸爸", "age": 40, "role": "self"}, {"name": "妈妈", "age": 38, "role": "spouse"}]},
"insurance": {"life": 0, "health": 0, "property": 0}
}
result = handle(request)
assert result["success"] == True
data = result["data"]
assert "coverageGaps" in data
assert "recommendations" in data
print(f"✓ insurance: totalGap={data['totalRecommendedCoverage']}, recs={len(data['recommendations'])}")
return data
def test_risk_warning():
"""Test risk warning"""
request = {
"action": "risk-warning",
"family": {"monthlyIncome": 20000, "incomeStability": "low", "members": []},
"assets": {"liquid": 10000, "investments": 0, "property": 0, "other": 0},
"liabilities": {"mortgage": 0, "loans": 50000, "creditCards": 10000},
"monthlyExpenses": {"housing": 5000, "transportation": 2000, "food": 3000, "healthcare": 1000, "education": 0, "entertainment": 2000, "other": 2000}
}
result = handle(request)
assert result["success"] == True
data = result["data"]
assert "overallRiskLevel" in data
assert "riskFactors" in data
print(f"✓ risk-warning: level={data['overallRiskLevel']}, factors={len(data['riskFactors'])}")
return data
if __name__ == "__main__":
print("Testing Family Finance Manager...\n")
test_health_report()
test_analyze()
test_goal_plan()
test_insurance()
test_risk_warning()
print("\n✅ All tests passed!")
FILE:skill.json
{
"name": "family-finance-manager",
"version": "0.1.0",
"description": "Family Finance Health Manager / 家庭财务健康管家. Provides comprehensive family financial management including income/expense analysis, savings goal breakdown, insurance recommendations, and financial health reporting.",
"keywords": ["finance","family","budget","savings","tracking"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/family-finance-manager",
"language": ["en","zh"],
"tags": ["finance","family","budget","savings","tracking"],
"createdAt": "2026-04-06",
"updatedAt": "2026-04-06"
}
自动分析家庭照片和对话,生成结构化时间线故事,帮助回顾和珍藏家庭重要记忆。
# Family Memory Timeline - 家庭记忆时光机
## 功能说明
**家庭记忆时光机**是一个专门用于整理和呈现家庭记忆的skill。它通过分析家庭照片、视频和对话文字,自动生成结构化的时间线故事,帮助家庭成员回顾和珍藏重要时刻。
### 核心功能
1. **照片情感分类** - 自动分析照片内容,识别场景、人物、情感氛围
2. **对话摘要** - 从家庭对话文字中提取关键信息和情感要点
3. **时间线生成** - 基于时间戳和内容关联性,自动构建时间线
4. **故事叙述** - 将时间线转化为连贯的叙述性故事
## 使用方式
### 触发词
- `家庭记忆时光机`
- `家庭故事整理`
- `家庭照片时间线`
- `家庭回忆整理`
### 指令格式
#### 格式1:简单指令
```
整理家庭记忆 [时间范围] [故事风格]
```
示例:
- `整理家庭记忆 2023年`
- `整理家庭记忆 去年 温馨风格`
#### 格式2:详细指令
```
创建家庭时间线:
照片:[照片路径列表]
对话:[对话文本]
时间范围:[开始时间] 到 [结束时间]
风格:[风格偏好]
```
## 输入格式
### 请求参数 (CreateStoryRequest)
```json
{
"media": [
"/photos/vacation_2023.jpg",
{ "path": "/photos/birthday.png", "description": "春节家庭聚会", "timestamp": "2023-01-22T18:00:00" }
],
"conversations": [
{ "speaker": "妈妈", "content": "今天宝宝第一次走路了!", "timestamp": "2023-05-15T10:30:00" },
{ "speaker": "爸爸", "content": "太激动了,要记录下来!", "timestamp": "2023-05-15T10:31:00" }
],
"config": {
"style": { "narrative": "warm" },
"output": { "formats": ["json", "markdown"] }
},
"projectName": "2023家庭回忆"
}
```
### 参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| media | array | 否 | 媒体文件路径或对象数组 |
| conversations | array | 否 | 对话记录数组 |
| config | object | 否 | 配置选项 |
| projectName | string | 否 | 项目名称 |
### config配置
```json
{
"style": {
"narrative": "warm | humorous | formal | poetic | casual"
},
"output": {
"formats": ["json", "markdown"]
}
}
```
## 输出格式
### 成功响应
```json
{
"success": true,
"story": {
"id": "story_xxx",
"title": "我们的家庭故事",
"timeline": {
"events": [...],
"timeframe": { "start": "...", "end": "..." }
},
"chapters": [...],
"summary": {
"totalEvents": 5,
"emotionalHighlights": [...],
"keyMoments": [...]
},
"metadata": {
"generatedAt": "2026-04-05T...",
"version": "0.1.0"
}
},
"processing": {
"status": "completed",
"progress": 100
},
"outputContent": "..."
}
```
## 使用示例
### 示例1:整理年度家庭回忆
**输入:**
```
整理家庭记忆
我想整理2023年的家庭回忆。
照片在 /Users/家庭/照片/2023/ 目录下。
还有一些微信聊天记录。
请生成一个温馨风格的时间线故事。
```
### 示例2:创建宝宝成长记录
**输入:**
```
开始家庭记忆项目 "宝宝的成长第一年"
添加照片 /Users/家庭/宝宝/0-12个月/
添加对话记录:
- 妈妈:今天宝宝第一次翻身了!
- 爸爸:宝宝会坐起来了!
时间范围:2023年1月到12月
风格:温馨感人
生成时间线
```
## 输出示例(Markdown格式)
```markdown
# 我们的家庭故事
**生成时间**: 2026年4月5日 18:30:00
**时间范围**: 2023年1月1日 - 2023年12月31日
## 统计概览
- 总事件数: 5
- 照片/媒体: 2
- 对话记录: 3
## 时间线
### 2023-05-15 - 宝宝第一次翻身了
- 类型: conversation
- 描述: 今天宝宝第一次翻身了!
- 情感: warmth (85%置信度)
- 重要性: 7/10
## 故事章节
### 2023-05月的温馨时刻
时期: 2023-05-01 - 2023-05-31
情感基调: 温馨感人
叙述: 这个月的家庭生活充满了温暖和甜蜜...
## 情感亮点
- warmth: 3次 (代表: 宝宝第一次翻身了)
## 关键时刻
- 宝宝第一次翻身了 (重要性: 7/10)
---
*由家庭记忆时光机生成 | 版本: 0.1.0*
```
## 注意事项
1. 支持的图片格式:JPG、PNG、HEIC,单张不超过10MB
2. 对话处理单次不超过5000字
3. 支持中文和英文
4. 时间范围支持从1970年至今
5. 不接入真实第三方API,使用模拟数据
## 技术信息
- 版本: 0.1.0
- 类型: ES Module (handler.mjs)
- 依赖: 无外部依赖(纯模拟实现)
FILE:clawhub.json
{
"name": "family-memory-timeline",
"version": "0.1.0",
"description": "家庭记忆时光机 - 整理和呈现家庭记忆,自动生成时间线故事",
"keywords": ["family", "memory", "timeline", "photo", "story", "家庭", "记忆", "时光机"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/family-memory-timeline",
"language": ["en", "zh"],
"tags": ["family", "memory", "timeline", "photo", "story"],
"createdAt": "2026-04-05",
"updatedAt": "2026-04-05"
}
FILE:engine/types.js
// Family Memory Timeline - 主处理器
// 负责接收请求,分发到各处理模块,生成时间线和故事
export type MediaType = "photo" | "video" | "audio";
export type EmotionType = "joy" | "happiness" | "excitement" | "pride" | "warmth" | "tenderness" | "love" | "peace" | "calm" | "contentment" | "nostalgia" | "surprise" | "sadness";
export type StoryStyle = "warm" | "humorous" | "formal" | "poetic" | "casual";
FILE:engine/types.ts
// Family Memory Timeline 类型定义
export type MediaType = 'photo' | 'video' | 'audio';
export type EmotionType =
| 'joy' | 'happiness' | 'excitement' | 'pride'
| 'warmth' | 'tenderness' | 'love' | 'gratitude'
| 'peace' | 'calm' | 'contentment' | 'nostalgia'
| 'surprise' | 'amazement' | 'wonder'
| 'sadness' | 'melancholy' | 'missing';
export type StoryStyle = 'warm' | 'humorous' | 'formal' | 'poetic' | 'casual';
export type OutputFormat = 'json' | 'markdown' | 'html';
export interface MediaFile {
id: string;
path: string;
type: MediaType;
timestamp?: string;
description?: string;
metadata: {
size: number;
format: string;
dimensions?: { width: number; height: number };
duration?: number;
};
}
export interface DialogueMessage {
id: string;
speaker: string;
content: string;
timestamp: string;
platform?: string;
}
export interface TimelineEvent {
id: string;
timestamp: string;
type: 'media' | 'conversation' | 'milestone' | 'custom';
title: string;
description: string;
mediaRefs: string[];
conversationRefs: string[];
emotion: {
primary: EmotionType;
secondary: EmotionType[];
intensity: number;
confidence: number;
triggers: string[];
};
significance: {
personal: number;
family: number;
calculated: number;
};
tags: string[];
processed: boolean;
narrativeGenerated: boolean;
}
export interface Timeline {
id: string;
title: string;
events: TimelineEvent[];
timeframe: { start: string; end: string };
statistics: {
totalEvents: number;
emotionDistribution: Record<EmotionType, number>;
};
}
export interface StoryChapter {
id: string;
title: string;
timeframe: { start: string; end: string };
timelineEvents: string[];
narrative: {
introduction: string;
development: string;
climax: string;
resolution: string;
};
emotionalArc: {
start: EmotionType;
climax: EmotionType;
resolution: EmotionType;
overallTone: string;
};
wordCount: number;
readingTime: number;
}
export interface FamilyStory {
id: string;
title: string;
timeline: Timeline;
chapters: StoryChapter[];
summary: {
timeframe: { start: string; end: string };
totalEvents: number;
totalMedia: number;
totalConversations: number;
emotionalHighlights: Array<{ emotion: EmotionType; count: number; representativeEvent: string }>;
keyMoments: Array<{ eventId: string; title: string; significance: number }>;
};
metadata: {
generatedAt: string;
processingTime: number;
version: string;
};
}
export interface StoryConfig {
timeframe?: { start?: string; end?: string };
style: {
narrative: StoryStyle;
tone: 'formal' | 'casual' | 'intimate';
perspective: 'first-person' | 'third-person' | 'collective';
detailLevel: 'brief' | 'standard' | 'detailed';
};
filters: {
minSignificance: number;
requiredPeople?: string[];
excludedEmotions?: EmotionType[];
};
output: {
formats: OutputFormat[];
includeVisualizations: boolean;
privacyLevel: 'private' | 'family' | 'public';
};
processing: {
enableSentimentAnalysis: boolean;
language: 'zh' | 'en' | 'auto';
parallelProcessing: boolean;
};
}
export interface CreateStoryRequest {
media?: Array<string | MediaFile>;
conversations?: Array<string | DialogueMessage>;
config: Partial<StoryConfig>;
projectName?: string;
description?: string;
}
export interface CreateStoryResponse {
success: boolean;
story?: FamilyStory;
error?: { code: string; message: string };
processing: {
status: 'pending' | 'processing' | 'completed' | 'failed';
progress: number;
currentStep?: string;
};
}
FILE:handler.mjs
// Family Memory Timeline - 主处理器
// 负责接收请求,分发到各处理模块,生成时间线和故事
const EmotionType = {
JOY: 'joy', HAPPINESS: 'happiness', EXCITEMENT: 'excitement', PRIDE: 'pride',
WARMTH: 'warmth', TENDERNESS: 'tenderness', LOVE: 'love', PEACE: 'peace',
CALM: 'calm', CONTENTMENT: 'contentment', NOSTALGIA: 'nostalgia', SURPRISE: 'surprise', SADNESS: 'sadness'
};
const StoryStyle = {
WARM: 'warm', HUMOROUS: 'humorous', FORMAL: 'formal', POETIC: 'poetic', CASUAL: 'casual'
};
function generateId(prefix) {
return prefix + '_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
function analyzeEmotion(text) {
const emotionKeywords = {
'开心': 'joy', '快乐': 'joy', '幸福': 'warmth', '温馨': 'warmth',
'激动': 'excitement', '骄傲': 'pride', '惊喜': 'surprise',
'感动': 'warmth', '想念': 'nostalgia', '悲伤': 'sadness'
};
let primary = 'warmth';
for (const [keyword, emotion] of Object.entries(emotionKeywords)) {
if (text.includes(keyword)) { primary = emotion; break; }
}
return { primary, secondary: [], confidence: 0.85 };
}
function analyzeMediaContent(path, description) {
return analyzeEmotion(description || path);
}
function calculateSignificance(emotion, hasMedia) {
let score = 5;
if (hasMedia) score += 2;
return { personal: Math.min(10, score), family: Math.min(10, score) * 0.9, calculated: Math.min(10, score) };
}
function extractTitle(path, description) {
if (description) return description.substring(0, 20);
const filename = path.split('/').pop() || path;
return filename.substring(0, 20);
}
function buildTimeline(media, conversations) {
const events = [];
for (const m of media) {
const emotion = analyzeMediaContent(m.path, m.description);
events.push({
id: generateId('event'),
timestamp: m.timestamp || new Date().toISOString(),
type: 'media',
title: extractTitle(m.path, m.description),
description: m.description || '照片记录',
mediaRefs: [m.path],
conversationRefs: [],
emotion,
significance: calculateSignificance(emotion, true),
tags: [],
processed: true,
narrativeGenerated: false
});
}
for (const d of conversations) {
const emotion = analyzeEmotion(d.content);
events.push({
id: generateId('event'),
timestamp: d.timestamp,
type: 'conversation',
title: d.speaker + '的重要分享',
description: d.content,
mediaRefs: [],
conversationRefs: [d.content],
emotion,
significance: calculateSignificance(emotion, false),
tags: [],
processed: true,
narrativeGenerated: false
});
}
events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
const emotionDistribution = {};
for (const e of events) {
emotionDistribution[e.emotion.primary] = (emotionDistribution[e.emotion.primary] || 0) + 1;
}
return {
id: generateId('timeline'),
title: '家庭记忆时间线',
events,
timeframe: events.length > 0
? { start: events[0].timestamp, end: events[events.length - 1].timestamp }
: { start: new Date().toISOString(), end: new Date().toISOString() },
statistics: { totalEvents: events.length, emotionDistribution }
};
}
function getStyleNarrative(style) {
const templates = {
warm: { intro: '这个月的家庭生活充满了温暖和甜蜜...', dev: '一个个平凡的日子,因为家人的陪伴而变得不平凡...', climax: '最让人印象深刻的是那些充满温情的时刻...', res: '让我们珍惜每一个与家人在一起的瞬间...' },
humorous: { intro: '这个月的家庭生活简直是喜剧片现场...', dev: '家里每天都热闹非凡,笑声不断...', climax: '最逗的要数那次...', res: '生活就像一盒巧克力,你永远不知道下一颗是什么味道...' },
formal: { intro: '本月家庭事务记录如下...', dev: '家庭成员共同参与了一系列重要活动...', climax: '其中具有重要意义的是...', res: '总体而言,本月家庭生活秩序井然...' },
poetic: { intro: '时光如流水,这个月的记忆如同诗篇...', dev: '每一个平凡的瞬间都闪耀着家的光芒...', climax: '那最动人的篇章...', res: '岁月静好,与家人同行的每一天都是礼物...' },
casual: { intro: '这个月家里发生了不少事...', dev: '来来来,听我慢慢说...', climax: '最精彩的是那次...', res: '总之,这个月很开心!' }
};
return templates[style] || templates.warm;
}
function getToneDescription(emotion) {
const desc = { warmth: '温馨感人', joy: '欢快愉悦', excitement: '激动人心', pride: '骄傲自豪', love: '充满爱意', nostalgia: '怀念感恩', contentment: '宁静满足', calm: '平和宁静' };
return desc[emotion] || '温暖人心';
}
function generateChapters(timeline, config) {
if (timeline.events.length === 0) return [];
const style = config?.style?.narrative || 'warm';
const chapters = [];
const monthGroups = new Map();
for (const event of timeline.events) {
const date = new Date(event.timestamp);
const monthKey = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0');
if (!monthGroups.has(monthKey)) monthGroups.set(monthKey, []);
monthGroups.get(monthKey).push(event);
}
for (const [month, events] of monthGroups) {
const firstDate = new Date(events[0].timestamp);
const lastDate = new Date(events[events.length - 1].timestamp);
const dominant = {};
for (const e of events) dominant[e.emotion.primary] = (dominant[e.emotion.primary] || 0) + 1;
const primaryEmotion = Object.entries(dominant).sort((a, b) => b[1] - a[1])[0]?.[0] || 'warmth';
const tmpl = getStyleNarrative(style);
const eventSummaries = events.slice(0, 3).map(e => e.description).join(';');
chapters.push({
id: generateId('chapter'),
title: month + '月的温馨时刻',
timeframe: { start: firstDate.toISOString(), end: lastDate.toISOString() },
timelineEvents: events.map(e => e.id),
narrative: { introduction: tmpl.intro + (eventSummaries ? ' ' + eventSummaries : ''), development: tmpl.dev, climax: tmpl.climax + ' ' + (events[0]?.title || '那些特别的日子'), resolution: tmpl.res },
emotionalArc: { start: 'calm', climax: primaryEmotion, resolution: 'contentment', overallTone: getToneDescription(primaryEmotion) },
wordCount: 200 + events.length * 50,
readingTime: Math.ceil((200 + events.length * 50) / 500)
});
}
return chapters;
}
function generateStory(request) {
const startTime = Date.now();
const mediaList = (request.media || []).map(m => typeof m === 'string' ? { path: m } : { path: m.path, description: m.description, timestamp: m.timestamp });
const dialogueList = (request.conversations || []).map(d => typeof d === 'string' ? { speaker: '家人', content: d, timestamp: new Date().toISOString() } : { speaker: d.speaker, content: d.content, timestamp: d.timestamp });
const timeline = buildTimeline(mediaList, dialogueList);
const chapters = generateChapters(timeline, request.config);
const emotionalHighlights = Object.entries(timeline.statistics.emotionDistribution).map(([emotion, count]) => ({ emotion, count, representativeEvent: timeline.events.find(e => e.emotion.primary === emotion)?.title || '' })).sort((a, b) => b.count - a.count);
const keyMoments = [...timeline.events].sort((a, b) => b.significance.calculated - a.significance.calculated).slice(0, 5).map(e => ({ eventId: e.id, title: e.title, significance: e.significance.calculated }));
return {
id: generateId('story'),
title: request.projectName || '我们的家庭故事',
timeline,
chapters,
summary: { timeframe: timeline.timeframe, totalEvents: timeline.events.length, totalMedia: mediaList.length, totalConversations: dialogueList.length, emotionalHighlights, keyMoments },
metadata: { generatedAt: new Date().toISOString(), processingTime: Date.now() - startTime, version: '0.1.0' }
};
}
function toMarkdown(story) {
const lines = [];
lines.push('# ' + story.title);
lines.push('');
lines.push('**生成时间**: ' + new Date(story.metadata.generatedAt).toLocaleString('zh-CN'));
lines.push('**时间范围**: ' + new Date(story.summary.timeframe.start).toLocaleDateString('zh-CN') + ' - ' + new Date(story.summary.timeframe.end).toLocaleDateString('zh-CN'));
lines.push('');
lines.push('## 统计概览');
lines.push('- 总事件数: ' + story.summary.totalEvents);
lines.push('- 照片/媒体: ' + story.summary.totalMedia);
lines.push('- 对话记录: ' + story.summary.totalConversations);
lines.push('');
lines.push('## 时间线');
for (const event of story.timeline.events) {
lines.push('### ' + new Date(event.timestamp).toLocaleDateString('zh-CN') + ' - ' + event.title);
lines.push('- 类型: ' + event.type);
lines.push('- 描述: ' + event.description);
lines.push('- 情感: ' + event.emotion.primary + ' (' + Math.round(event.emotion.confidence * 100) + '%置信度)');
lines.push('- 重要性: ' + event.significance.calculated + '/10');
lines.push('');
}
lines.push('## 故事章节');
for (const ch of story.chapters) {
lines.push('### ' + ch.title);
lines.push('时期: ' + new Date(ch.timeframe.start).toLocaleDateString('zh-CN') + ' - ' + new Date(ch.timeframe.end).toLocaleDateString('zh-CN'));
lines.push('情感基调: ' + ch.emotionalArc.overallTone);
lines.push('叙述: ' + ch.narrative.introduction);
lines.push('');
}
lines.push('## 情感亮点');
for (const h of story.summary.emotionalHighlights) lines.push('- ' + h.emotion + ': ' + h.count + '次 (代表: ' + h.representativeEvent + ')');
lines.push('');
lines.push('## 关键时刻');
for (const m of story.summary.keyMoments) lines.push('- ' + m.title + ' (重要性: ' + m.significance + '/10)');
lines.push('');
lines.push('---');
lines.push('*由家庭记忆时光机生成 | 版本: ' + story.metadata.version + '*');
return lines.join('\n');
}
export async function handleFamilyMemoryTimeline(request) {
try {
const story = generateStory(request);
const outputContent = (request.config?.output?.formats || []).includes('markdown') ? toMarkdown(story) : JSON.stringify(story, null, 2);
return { success: true, story, processing: { status: 'completed', progress: 100 }, outputContent };
} catch (error) {
return { success: false, error: { code: 'UNKNOWN_ERROR', message: error.message }, processing: { status: 'failed', progress: 0 } };
}
}
export default { handleFamilyMemoryTimeline };
FILE:handler.ts
// Family Memory Timeline - 主处理器
// 负责接收请求,分发到各处理模块,生成时间线和故事
import type {
CreateStoryRequest,
CreateStoryResponse,
FamilyStory,
TimelineEvent,
StoryChapter,
Timeline,
EmotionType,
StoryStyle
} from './engine/types.js';
// 情感分析引擎(模拟)
function analyzeEmotion(text: string): { primary: EmotionType; secondary: EmotionType[]; confidence: number } {
const emotionKeywords: Record<string, EmotionType[]> = {
'开心': ['joy', 'happiness'],
'快乐': ['joy', 'happiness'],
'幸福': ['warmth', 'contentment'],
'温馨': ['warmth', 'tenderness'],
'激动': ['excitement', 'pride'],
'骄傲': ['pride'],
'惊喜': ['surprise', 'excitement'],
'感动': ['warmth', 'gratitude'],
'想念': ['nostalgia', 'missing'],
'悲伤': ['sadness', 'melancholy'],
};
let primary: EmotionType = 'warmth';
let confidence = 0.7;
for (const [keyword, emotions] of Object.entries(emotionKeywords)) {
if (text.includes(keyword)) {
primary = emotions[0];
confidence = 0.85;
break;
}
}
return { primary, secondary: [], confidence };
}
// 模拟媒体文件分析
function analyzeMediaContent(path: string, description?: string): TimelineEvent['emotion'] {
const text = description || path;
return analyzeEmotion(text);
}
// 模拟对话情感分析
function analyzeDialogueEmotion(content: string): TimelineEvent['emotion'] {
return analyzeEmotion(content);
}
// 计算事件重要性
function calculateSignificance(emotion: TimelineEvent['emotion'], hasMedia: boolean): { personal: number; family: number; calculated: number } {
let score = emotion.intensity || 5;
if (hasMedia) score += 2;
score = Math.min(10, score);
return {
personal: score,
family: score * 0.9,
calculated: score
};
}
// 生成唯一ID
function generateId(prefix: string): string {
return prefix + '_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
// 构建时间线
function buildTimeline(
media: Array<{ path: string; description?: string; timestamp?: string }>,
conversations: Array<{ speaker: string; content: string; timestamp: string }>,
config: CreateStoryRequest['config']
): Timeline {
const events: TimelineEvent[] = [];
// 处理媒体文件
for (const m of media) {
const timestamp = m.timestamp || new Date().toISOString();
const emotion = analyzeMediaContent(m.path, m.description);
events.push({
id: generateId('event'),
timestamp,
type: 'media',
title: extractTitle(m.path, m.description),
description: m.description || '照片记录',
mediaRefs: [m.path],
conversationRefs: [],
emotion,
significance: calculateSignificance(emotion, true),
tags: [],
processed: true,
narrativeGenerated: false
});
}
// 处理对话
for (const d of conversations) {
const emotion = analyzeDialogueEmotion(d.content);
events.push({
id: generateId('event'),
timestamp: d.timestamp,
type: 'conversation',
title: d.speaker + '的重要分享',
description: d.content,
mediaRefs: [],
conversationRefs: [d.content],
emotion,
significance: calculateSignificance(emotion, false),
tags: [],
processed: true,
narrativeGenerated: false
});
}
// 按时间排序
events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
// 统计数据
const emotionDistribution: Record<EmotionType, number> = {} as Record<EmotionType, number>;
for (const e of events) {
emotionDistribution[e.emotion.primary] = (emotionDistribution[e.emotion.primary] || 0) + 1;
}
const timeRange = events.length > 0
? { start: events[0].timestamp, end: events[events.length - 1].timestamp }
: { start: new Date().toISOString(), end: new Date().toISOString() };
return {
id: generateId('timeline'),
title: '家庭记忆时间线',
events,
timeframe: timeRange,
statistics: {
totalEvents: events.length,
emotionDistribution
}
};
}
// 从路径或描述中提取标题
function extractTitle(path: string, description?: string): string {
if (description) {
return description.substring(0, 20);
}
const filename = path.split('/').pop() || path;
return filename.substring(0, 20);
}
// 生成分章故事
function generateChapters(timeline: Timeline, config: CreateStoryRequest['config']): StoryChapter[] {
if (timeline.events.length === 0) {
return [];
}
const style: StoryStyle = config?.style?.narrative || 'warm';
const chapters: StoryChapter[] = [];
// 按月份分组
const monthGroups = new Map<string, TimelineEvent[]>();
for (const event of timeline.events) {
const date = new Date(event.timestamp);
const monthKey = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0');
if (!monthGroups.has(monthKey)) {
monthGroups.set(monthKey, []);
}
monthGroups.get(monthKey)!.push(event);
}
// 为每个月份生成章节
for (const [month, events] of monthGroups) {
const firstDate = new Date(events[0].timestamp);
const lastDate = new Date(events[events.length - 1].timestamp);
const dominantEmotion = events.reduce((acc, e) => {
acc[e.emotion.primary] = (acc[e.emotion.primary] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const primaryEmotion = Object.entries(dominantEmotion)
.sort((a, b) => b[1] - a[1])[0]?.[0] as EmotionType || 'warmth';
chapters.push({
id: generateId('chapter'),
title: month + '月的温馨时刻',
timeframe: {
start: firstDate.toISOString(),
end: lastDate.toISOString()
},
timelineEvents: events.map(e => e.id),
narrative: generateChapterNarrative(events, style, primaryEmotion),
emotionalArc: {
start: 'calm',
climax: primaryEmotion,
resolution: 'contentment',
overallTone: getToneDescription(primaryEmotion, style)
},
wordCount: 200 + events.length * 50,
readingTime: Math.ceil((200 + events.length * 50) / 500)
});
}
return chapters;
}
// 生成章节叙述文本
function generateChapterNarrative(events: TimelineEvent[], style: StoryStyle, emotion: EmotionType): StoryChapter['narrative'] {
const styleTemplates: Record<StoryStyle, { intro: string; dev: string; climax: string; res: string }> = {
warm: {
intro: '这个月的家庭生活充满了温暖和甜蜜...',
dev: '一个个平凡的日子,因为家人的陪伴而变得不平凡...',
climax: '最让人印象深刻的是那些充满温情的时刻...',
res: '让我们珍惜每一个与家人在一起的瞬间...'
},
humorous: {
intro: '这个月的家庭生活简直是喜剧片现场...',
dev: '家里每天都热闹非凡,笑声不断...',
climax: '最逗的要数那次...',
res: '生活就像一盒巧克力,你永远不知道下一颗是什么味道...'
},
formal: {
intro: '本月家庭事务记录如下...',
dev: '家庭成员共同参与了一系列重要活动...',
climax: '其中具有重要意义的是...',
res: '总体而言,本月家庭生活秩序井然...'
},
poetic: {
intro: '时光如流水,这个月的记忆如同诗篇...',
dev: '每一个平凡的瞬间都闪耀着家的光芒...',
climax: '那最动人的篇章...',
res: '岁月静好,与家人同行的每一天都是礼物...'
},
casual: {
intro: '这个月家里发生了不少事...',
dev: '来来来,听我慢慢说...',
climax: '最精彩的是那次...',
res: '总之,这个月很开心!'
}
};
const template = styleTemplates[style] || styleTemplates.warm;
const eventSummaries = events.slice(0, 3).map(e => e.description).join(';');
return {
introduction: template.intro + (eventSummaries ? ' ' + eventSummaries : ''),
development: template.dev,
climax: template.climax + ' ' + (events[0]?.title || '那些特别的日子'),
resolution: template.res
};
}
// 获取情感风格描述
function getToneDescription(emotion: EmotionType, style: StoryStyle): string {
const descriptions: Record<string, string> = {
'warmth': '温馨感人',
'joy': '欢快愉悦',
'excitement': '激动人心',
'pride': '骄傲自豪',
'love': '充满爱意',
'nostalgia': '怀念感恩',
'contentment': '宁静满足',
'calm': '平和宁静'
};
return descriptions[emotion] || '温暖人心';
}
// 生成完整故事
function generateStory(request: CreateStoryRequest): FamilyStory {
const startTime = Date.now();
const mediaList = (request.media || []).map(m => {
if (typeof m === 'string') {
return { path: m, description: undefined, timestamp: undefined };
}
return { path: m.path, description: m.description, timestamp: m.timestamp };
});
const dialogueList = (request.conversations || []).map(d => {
if (typeof d === 'string') {
return { speaker: '家人', content: d, timestamp: new Date().toISOString() };
}
return { speaker: d.speaker, content: d.content, timestamp: d.timestamp };
});
const timeline = buildTimeline(mediaList, dialogueList, request.config);
const chapters = generateChapters(timeline, request.config);
const emotionalHighlights = Object.entries(timeline.statistics.emotionDistribution)
.map(([emotion, count]) => ({
emotion: emotion as EmotionType,
count,
representativeEvent: timeline.events.find(e => e.emotion.primary === emotion)?.title || ''
}))
.sort((a, b) => b.count - a.count);
const keyMoments = [...timeline.events]
.sort((a, b) => b.significance.calculated - a.significance.calculated)
.slice(0, 5)
.map(e => ({
eventId: e.id,
title: e.title,
significance: e.significance.calculated
}));
const endTime = Date.now();
return {
id: generateId('story'),
title: request.projectName || '我们的家庭故事',
timeline,
chapters,
summary: {
timeframe: timeline.timeframe,
totalEvents: timeline.events.length,
totalMedia: mediaList.length,
totalConversations: dialogueList.length,
emotionalHighlights,
keyMoments
},
metadata: {
generatedAt: new Date().toISOString(),
processingTime: endTime - startTime,
version: '0.1.0'
}
};
}
// 将故事转换为Markdown格式
function toMarkdown(story: FamilyStory): string {
const lines: string[] = [];
lines.push('# ' + story.title);
lines.push('');
lines.push('**生成时间**: ' + new Date(story.metadata.generatedAt).toLocaleString('zh-CN'));
lines.push('**时间范围**: ' + new Date(story.summary.timeframe.start).toLocaleDateString('zh-CN') + ' - ' + new Date(story.summary.timeframe.end).toLocaleDateString('zh-CN'));
lines.push('');
lines.push('## 📊 统计概览');
lines.push('');
lines.push('- 总事件数: ' + story.summary.totalEvents);
lines.push('- 照片/媒体: ' + story.summary.totalMedia);
lines.push('- 对话记录: ' + story.summary.totalConversations);
lines.push('');
lines.push('## 📅 时间线');
lines.push('');
for (const event of story.timeline.events) {
lines.push('### ' + new Date(event.timestamp).toLocaleDateString('zh-CN') + ' - ' + event.title);
lines.push('');
lines.push('- **类型**: ' + event.type);
lines.push('- **描述**: ' + event.description);
lines.push('- **情感**: ' + event.emotion.primary + ' (' + Math.round(event.emotion.confidence * 100) + '%置信度)');
lines.push('- **重要性**: ' + event.significance.calculated + '/10');
lines.push('');
}
lines.push('## 📖 故事章节');
lines.push('');
for (const chapter of story.chapters) {
lines.push('### ' + chapter.title);
lines.push('');
lines.push('**时期**: ' + new Date(chapter.timeframe.start).toLocaleDateString('zh-CN') + ' - ' + new Date(chapter.timeframe.end).toLocaleDateString('zh-CN'));
lines.push('');
lines.push('**情感基调**: ' + chapter.emotionalArc.overallTone);
lines.push('');
lines.push('**叙述**: ' + chapter.narrative.introduction);
lines.push('');
lines.push('---');
lines.push('');
}
lines.push('## 💡 情感亮点');
lines.push('');
for (const highlight of story.summary.emotionalHighlights) {
lines.push('- **' + highlight.emotion + '**: ' + highlight.count + '次 (代表: ' + highlight.representativeEvent + ')');
}
lines.push('');
lines.push('## ⭐ 关键时刻');
lines.push('');
for (const moment of story.summary.keyMoments) {
lines.push('- ' + moment.title + ' (重要性: ' + moment.significance + '/10)');
}
lines.push('');
lines.push('---');
lines.push('*由家庭记忆时光机生成 | 版本: ' + story.metadata.version + '*');
return lines.join('\n');
}
// 将故事转换为JSON格式
function toJSON(story: FamilyStory): string {
return JSON.stringify(story, null, 2);
}
// 主入口函数
export async function handleFamilyMemoryTimeline(request: CreateStoryRequest): Promise<CreateStoryResponse> {
try {
const story = generateStory(request);
const formats = request.config?.output?.formats || ['json'];
let outputContent = '';
if (formats.includes('markdown')) {
outputContent = toMarkdown(story);
} else {
outputContent = toJSON(story);
}
return {
success: true,
story,
processing: {
status: 'completed',
progress: 100
}
};
} catch (error) {
return {
success: false,
error: {
code: 'UNKNOWN_ERROR',
message: error instanceof Error ? error.message : '处理失败'
},
processing: {
status: 'failed',
progress: 0
}
};
}
}
// 兼容 CommonJS
export default { handleFamilyMemoryTimeline };
FILE:package.json
{
"name": "family-memory-timeline",
"version": "0.1.0",
"description": "家庭记忆时光机 - 整理和呈现家庭记忆,自动生成时间线故事",
"type": "module",
"main": "handler.js",
"scripts": {
"test": "node scripts/test-stub.js"
},
"keywords": ["family", "memory", "timeline", "photo", "story", "家庭", "记忆", "时光机"],
"author": "harrylabsj",
"license": "MIT",
"language": ["en", "zh"],
"tags": ["family", "memory", "timeline", "photo", "story"]
}
FILE:scripts/test-stub.js
// 测试脚本 - Family Memory Timeline
import { handleFamilyMemoryTimeline } from '../handler.mjs';
async function runTest() {
console.log('=== Family Memory Timeline 自测 ===\n');
// 测试用例
const testRequest = {
media: [
'/photos/vacation_2023.jpg',
{ path: '/photos/birthday.png', description: '春节家庭聚会温馨时刻', timestamp: '2023-01-22T18:00:00' }
],
conversations: [
{ speaker: '妈妈', content: '今天宝宝第一次走路了,太激动了!', timestamp: '2023-05-15T10:30:00' },
{ speaker: '爸爸', content: '要记录下来这珍贵的时刻', timestamp: '2023-05-15T10:31:00' },
{ speaker: '奶奶', content: '宝宝真棒!', timestamp: '2023-05-15T10:32:00' }
],
config: {
style: { narrative: 'warm' },
output: { formats: ['json', 'markdown'] }
},
projectName: '2023家庭回忆'
};
console.log('测试输入:');
console.log('- 媒体文件: 2个');
console.log('- 对话记录: 3条');
console.log('- 故事风格: warm (温馨)');
console.log('');
try {
const result = await handleFamilyMemoryTimeline(testRequest);
if (result.success) {
console.log('✅ 处理成功!\n');
console.log('输出摘要:');
console.log('- story.id:', result.story.id);
console.log('- story.title:', result.story.title);
console.log('- timeline.events count:', result.story.timeline.events.length);
console.log('- chapters count:', result.story.chapters.length);
console.log('- totalEvents:', result.story.summary.totalEvents);
console.log('- processingTime:', result.story.metadata.processingTime, 'ms');
console.log('');
console.log('情感分布:');
for (const [emotion, count] of Object.entries(result.story.timeline.statistics.emotionDistribution)) {
console.log(' -', emotion, ':', count);
}
console.log('');
console.log('关键时刻:');
for (const m of result.story.summary.keyMoments.slice(0, 3)) {
console.log(' -', m.title, '(重要性:', m.significance + '/10)');
}
console.log('');
console.log('Markdown输出预览 (前500字符):');
console.log(result.outputContent.substring(0, 500));
console.log('...\n');
console.log('=== 自测通过 ===');
} else {
console.log('❌ 处理失败:', result.error);
}
} catch (error) {
console.log('❌ 测试异常:', error.message);
}
}
runTest();
FILE:skill.json
{
"name": "family-memory-timeline",
"version": "0.1.0",
"description": "家庭记忆时光机 - 整理和呈现家庭记忆的 skill. 通过分析家庭照片、视频和对话文字,自动生成结构化的时间线故事。",
"keywords": ["family","memory","timeline","photos","stories"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/family-memory-timeline",
"language": ["en","zh"],
"tags": ["family","memory","timeline","photos","stories"],
"createdAt": "2026-04-06",
"updatedAt": "2026-04-06"
}
Health Habit Builder / 健康习惯养成师. 基于行为科学和习惯形成理论,智能拆解微习惯、跟踪打卡、分析动机,帮助用户建立可持续的健康习惯。
---
name: health-habit-builder
slug: health-habit-builder
version: 0.1.0
description: |
Health Habit Builder / 健康习惯养成师.
基于行为科学和习惯形成理论,智能拆解微习惯、跟踪打卡、分析动机,帮助用户建立可持续的健康习惯。
---
# Health Habit Builder / 健康习惯养成师
你是**健康习惯养成师**,基于行为科学和习惯形成理论,帮助用户建立可持续的健康习惯。
## 产品定位
Health Habit Builder 通过以下核心能力帮助用户养成健康习惯:
- **习惯难度评估**:科学评估新习惯的难度和成功概率
- **微习惯拆分**:将大目标拆解为2分钟内可完成的微习惯
- **每日打卡系统**:跟踪每日习惯执行情况和连续记录
- **动机分析**:分析用户动机水平和变化趋势,提供强化建议
## 核心功能
### 1. 习惯难度评估
- 输入目标习惯描述、用户当前状态
- 输出难度评分(1-10)、成功概率预测、主要障碍分析
### 2. 微习惯生成
- 将大目标拆解为渐进式微习惯序列
- 确保每个微习惯在2分钟内可完成
- 提供每日最小承诺
### 3. 每日打卡
- 记录完成状态、坚持天数、连续记录
- 支持完成质量反馈
### 4. 动机分析
- 分析内在/外在动机水平
- 趋势预测和衰减预警
- 提供强化建议
## 输入格式
```typescript
interface HabitRequest {
intent: "create" | "evaluate" | "checkIn" | "progress" | "adjust" | "motivate";
habit?: {
name: string;
description?: string;
frequency: string;
startDate?: string;
targetDate?: string;
};
habitId?: string;
userContext?: {
currentHabits?: string[];
availableTime?: string;
pastFailures?: string;
motivationType?: string;
};
feedback?: {
status: "completed" | "skipped" | "partial";
quality?: number;
notes?: string;
mood?: number;
energy?: number;
};
adjustment?: {
type: "goal" | "schedule" | "difficulty";
description: string;
};
}
```
## 输出格式
```typescript
interface HabitResponse {
success: boolean;
habitPlan?: {...};
evaluation?: {...};
checkInResult?: {...};
progressReport?: {...};
motivationAnalysis?: {...};
adjustmentSuggestion?: {...};
error?: {...};
}
```
## 触发词
- 健康习惯养成
- 习惯养成师
- 建立新习惯
- 习惯跟踪
- 微习惯计划
- 评估 [习惯] 难度
- 打卡完成 [习惯]
- 查看习惯进度
- 我在 [习惯] 上遇到困难
## 当前状态
- 习惯评估:stub
- 微习惯生成:stub
- 打卡系统:stub
- 动机分析:stub
## 目录结构
```
health-habit-builder/
├── SKILL.md
├── clawhub.json
├── package.json
├── handler.py
├── engine/
│ ├── types.py
│ ├── assessor.py
│ ├── microhabit.py
│ ├── tracker.py
│ └── motivator.py
└── scripts/
└── test_handler.py
```
FILE:clawhub.json
{
"name": "health-habit-builder",
"version": "0.1.0",
"description": "健康习惯养成师 - 基于行为科学和习惯形成理论,智能拆解微习惯、跟踪打卡、分析动机,帮助用户建立可持续的健康习惯",
"keywords": ["health", "habit", "wellness", "productivity", "mindfulness", "motivation", "tracking"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/health-habit-builder"
}
FILE:engine/__init__.py
# Engine module
FILE:engine/assessor.py
"""
Health Habit Builder - Difficulty Assessor
健康习惯养成师 - 难度评估引擎
"""
import random
from typing import Dict, Any
def assess_habit_difficulty(habit_name: str, user_context: Dict[str, Any]) -> Dict[str, Any]:
"""
评估习惯难度
Args:
habit_name: 习惯名称
user_context: 用户上下文
Returns:
难度评估结果
"""
# 基于关键词估算基础难度
base_difficulty = _estimate_base_difficulty(habit_name)
# 根据用户历史调整
past_failures = user_context.get("pastFailures", "")
if past_failures:
if "时间" in past_failures or "忙" in past_failures:
base_difficulty += 1
if "坚持" in past_failures:
base_difficulty += 1
if "懒" in past_failures:
base_difficulty += 1
# 限制在1-10范围
overall = max(1, min(10, base_difficulty))
# 计算成功概率
success_probability = _calculate_success_probability(overall, user_context)
# 估算形成时间
estimated_time = _estimate_formation_time(overall)
# 难度因素分析
factors = _analyze_difficulty_factors(habit_name, overall)
return {
"overall": overall,
"factors": factors,
"successProbability": success_probability,
"estimatedFormationTime": estimated_time
}
def _estimate_base_difficulty(habit_name: str) -> int:
"""估算基础难度"""
difficulty_map = {
"冥想": 4,
"运动": 5,
"跑步": 5,
"健身": 6,
"瑜伽": 4,
"阅读": 3,
"写作": 5,
"早起": 6,
"早睡": 5,
"喝水": 2,
"刷牙": 1,
"冥想": 4,
"学习": 5,
"英语": 5,
"跑步": 5,
"走路": 2,
"散步": 2,
"休息": 3,
"午睡": 3,
}
for key, diff in difficulty_map.items():
if key in habit_name:
return diff
# 默认中等难度
return 5
def _calculate_success_probability(overall: int, user_context: Dict[str, Any]) -> float:
"""计算成功概率"""
# 基础概率
base_prob = 1 - (overall - 1) / 9 * 0.5
# 调整因素
motivation_type = user_context.get("motivationType", "")
if "内在" in motivation_type:
base_prob += 0.1
past_failures = user_context.get("pastFailures", "")
if past_failures:
base_prob -= 0.1
return max(0.1, min(0.95, base_prob))
def _estimate_formation_time(overall: int) -> int:
"""估算习惯形成时间"""
# 基于研究:平均66天,范围18-254天
base_time = 66
adjustment = (overall - 5) * 5
return max(18, min(254, base_time + adjustment))
def _analyze_difficulty_factors(habit_name: str, overall: int) -> list:
"""分析难度因素"""
factors = []
if overall >= 5:
factors.append({
"factor": "时间安排",
"impact": overall - 3,
"mitigation": "选择固定时间段,关联到已有习惯"
})
if overall >= 6:
factors.append({
"factor": "意志力消耗",
"impact": overall - 4,
"mitigation": "降低初始难度,建立仪式感"
})
factors.append({
"factor": "环境干扰",
"impact": max(1, overall - 5),
"mitigation": "创造有利的环境,减少干扰源"
})
if overall >= 7:
factors.append({
"factor": "动力衰减",
"impact": overall - 4,
"mitigation": "设置阶段性奖励,关注内在满足感"
})
return factors
FILE:engine/microhabit.py
"""
Health Habit Builder - Micro Habit Generator
健康习惯养成师 - 微习惯生成器
"""
from typing import Dict, Any, List
def generate_microhabits(habit_name: str, difficulty: int) -> List[Dict[str, Any]]:
"""
生成微习惯序列
Args:
habit_name: 习惯名称
difficulty: 难度等级 (1-10)
Returns:
微习惯序列
"""
microhabits = []
# 第一阶段:启动期 (1-2周)
gateway_task = _get_gateway_task(habit_name)
microhabits.append({
"week": "第1周",
"focus": "建立仪式感",
"dailyTask": gateway_task,
"successCriteria": "连续7天完成",
"reward": "内在:完成后的成就感;外在:解锁下一阶段"
})
# 第二阶段:学习期 (3-4周)
microhabits.append({
"week": "第2周",
"focus": "适应节奏",
"dailyTask": _get_level2_task(habit_name),
"successCriteria": "完成率>=80%",
"reward": "内在:感受到进步;外在:成就徽章"
})
# 第三阶段:整合期 (5-8周)
if difficulty <= 5:
microhabits.append({
"week": "第3-4周",
"focus": "形成习惯",
"dailyTask": _get_standard_task(habit_name),
"successCriteria": "能够自然完成,不需要提醒",
"reward": "习惯初步形成"
})
else:
microhabits.append({
"week": "第3-4周",
"focus": "延长时间/强度",
"dailyTask": _get_level3_task(habit_name),
"successCriteria": "连续14天稳定完成",
"reward": "进入巩固期"
})
# 第四阶段:维持期 (9-12周)
microhabits.append({
"week": "第5-8周",
"focus": "巩固与自动化",
"dailyTask": _get_maintenance_task(habit_name),
"successCriteria": "不需意志力即可完成",
"reward": "习惯进入自动化阶段"
})
return microhabits
def _get_gateway_task(habit_name: str) -> str:
"""获取入门级微习惯"""
gateways = {
"冥想": "坐下,深呼吸3次",
"运动": "穿上运动鞋",
"跑步": "系好鞋带",
"健身": "做1个俯卧撑",
"瑜伽": "铺开瑜伽垫",
"阅读": "打开书到第一页",
"写作": "写下一句话",
"早起": "比平时早5分钟起床",
"早睡": "提前10分钟躺在床上",
"喝水": "喝一口水",
"学习": "打开学习资料",
"英语": "背诵一个单词",
}
for key, task in gateways.items():
if key in habit_name:
return task
return f"做{habit_name}相关的最小行动"
def _get_level2_task(habit_name: str) -> str:
"""获取第二级微习惯"""
tasks = {
"冥想": "引导冥想1分钟",
"运动": "做5分钟热身",
"跑步": "慢跑1分钟",
"健身": "做3组简单动作",
"瑜伽": "做1个基础体式",
"阅读": "阅读1页",
"写作": "写50字",
"早起": "早起10分钟",
"早睡": "提前15分钟上床",
"喝水": "喝半杯水",
"学习": "学习5分钟",
"英语": "学习3个单词",
}
for key, task in tasks.items():
if key in habit_name:
return task
return f"完成{habit_name}的基础版本"
def _get_level3_task(habit_name: str) -> str:
"""获取第三级微习惯"""
tasks = {
"冥想": "冥想3分钟",
"运动": "运动10分钟",
"跑步": "跑步10分钟",
"健身": "完成15分钟训练",
"瑜伽": "练习10分钟",
"阅读": "阅读10分钟",
"写作": "写100字",
"早起": "早起15分钟",
"早睡": "提前20分钟上床",
"喝水": "喝完一杯水",
"学习": "学习15分钟",
"英语": "学习5个单词并造句",
}
for key, task in tasks.items():
if key in habit_name:
return task
return f"完成{habit_name}的标准版本"
def _get_standard_task(habit_name: str) -> str:
"""获取标准微习惯"""
tasks = {
"冥想": "冥想5-10分钟",
"运动": "运动15-20分钟",
"跑步": "跑步20分钟",
"健身": "完成30分钟训练",
"瑜伽": "练习15-20分钟",
"阅读": "阅读20页",
"写作": "写200字",
"早起": "按时早起",
"早睡": "按时入睡",
"喝水": "分次喝完8杯水",
"学习": "学习30分钟",
"英语": "学习并复习10个单词",
}
for key, task in tasks.items():
if key in habit_name:
return task
return f"完成{habit_name}的正常版本"
def _get_maintenance_task(habit_name: str) -> str:
"""获取维持期任务"""
return f"保持{habit_name}的习惯,尝试优化和调整"
FILE:engine/motivator.py
"""
Health Habit Builder - Motivator
健康习惯养成师 - 动机分析引擎
"""
from typing import Dict, Any, List
def analyze_motivation(habit_id: str, habit_data: Dict[str, Any] = None) -> Dict[str, Any]:
"""
分析动机
Args:
habit_id: 习惯ID
habit_data: 习惯数据
Returns:
动机分析结果
"""
# 模拟动机分析
streak = 7 if habit_data is None else habit_data.get("streak", {}).get("current", 0)
# 根据连续天数调整动机评分
if streak >= 21:
intrinsic = 85
extrinsic = 50
elif streak >= 7:
intrinsic = 72
extrinsic = 45
else:
intrinsic = 60
extrinsic = 40
overall = int((intrinsic * 0.6 + extrinsic * 0.4))
# 判断趋势
if streak >= 14:
trend = "improving"
elif streak >= 3:
trend = "stable"
else:
trend = "declining"
return {
"levels": {
"intrinsic": intrinsic,
"extrinsic": extrinsic,
"overall": overall
},
"trend": trend,
"positiveFactors": _get_positive_factors(streak),
"negativeFactors": _get_negative_factors(streak),
"recommendations": _get_recommendations(trend, intrinsic)
}
def _get_positive_factors(streak: int) -> List[str]:
"""获取积极因素"""
factors = []
if streak >= 7:
factors.append("已完成连续7天,信心增强")
if streak >= 14:
factors.append("进入第二周,习惯正在形成")
factors.append("开始感受到正向变化")
if streak >= 3:
factors.append("已有一定基础,不易放弃")
if not factors:
factors.append("刚开始,动机最强")
return factors
def _get_negative_factors(streak: int) -> List[str]:
"""获取消极因素"""
factors = []
if streak < 7:
factors.append("习惯尚未稳定,需要坚持")
if streak < 3:
factors.append("放弃概率较高,需要注意")
factors.append("可能遇到动力下降期")
return factors
def _get_recommendations(trend: str, intrinsic: int) -> List[Dict[str, Any]]:
"""获取建议"""
recommendations = []
if trend == "declining":
recommendations.append({
"type": "boost",
"action": "尝试不同的时间或地点增加新鲜感",
"priority": "high"
})
recommendations.append({
"type": "recover",
"action": "回顾最初的动力,重新聚焦目标",
"priority": "high"
})
elif trend == "stable":
recommendations.append({
"type": "sustain",
"action": "继续关注内在感受而非外在奖励",
"priority": "medium"
})
recommendations.append({
"type": "boost",
"action": "加入一点变化防止枯燥",
"priority": "low"
})
else: # improving
recommendations.append({
"type": "sustain",
"action": "保持当前节奏,注意不要过度",
"priority": "high"
})
return recommendations
FILE:engine/tracker.py
"""
Health Habit Builder - Tracker
健康习惯养成师 - 打卡跟踪器
"""
from datetime import datetime
from typing import Dict, Any, Optional
def check_in(habit_id: str, feedback: Dict[str, Any]) -> Dict[str, Any]:
"""
处理打卡
Args:
habit_id: 习惯ID
feedback: 打卡反馈
Returns:
打卡结果
"""
status = feedback.get("status", "completed")
date = datetime.now().strftime("%Y-%m-%d")
result = {
"habitId": habit_id,
"date": date,
"status": status,
"currentStreak": 1,
"longestStreak": 1,
"totalCompleted": 1,
"message": ""
}
if status == "completed":
result["currentStreak"] = feedback.get("streak", 1) + 1
result["longestStreak"] = max(result["currentStreak"], feedback.get("longestStreak", 1))
result["totalCompleted"] = feedback.get("totalCompleted", 0) + 1
result["message"] = f"太棒了!已连续完成 {result['currentStreak']} 天"
elif status == "skipped":
result["currentStreak"] = 0
result["message"] = "今天跳过了,没关系,明天继续加油"
else:
result["currentStreak"] = 0
result["message"] = "部分完成,继续努力"
return result
def get_progress(habit_id: str, habit_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
获取进度报告
Args:
habit_id: 习惯ID
habit_data: 习惯数据
Returns:
进度报告
"""
if habit_data is None:
habit_data = {
"name": "未知习惯",
"currentPhase": "initiation",
"streak": {"current": 0, "longest": 0, "totalDays": 0},
"completionRate": 0,
"history": []
}
return {
"habitId": habit_id,
"habitName": habit_data.get("name", "未知习惯"),
"currentPhase": _get_phase_name(habit_data.get("currentPhase", "initiation")),
"streak": habit_data.get("streak", {"current": 0, "longest": 0, "totalDays": 0}),
"completionRate": habit_data.get("completionRate", 0),
"consistencyScore": _calc_consistency(habit_data),
"recentHistory": habit_data.get("history", [])[-10:],
"motivationLevel": {
"current": 75,
"trend": "stable"
},
"nextMilestone": {
"daysRemaining": max(0, 21 - habit_data.get("streak", {}).get("totalDays", 0)),
"reward": "21天徽章"
}
}
def _calc_consistency(habit_data: Dict[str, Any]) -> int:
"""计算一致性评分"""
streak = habit_data.get("streak", {})
total = streak.get("totalDays", 0)
longest = streak.get("longest", 0)
if total == 0:
return 0
return int(longest / total * 100)
def _get_phase_name(phase: str) -> str:
"""获取阶段中文名称"""
names = {
"initiation": "启动期",
"learning": "学习期",
"integration": "整合期",
"maintenance": "维持期",
"mastery": "精通期"
}
return names.get(phase, "启动期")
FILE:engine/types.py
"""
Health Habit Builder - Type Definitions
健康习惯养成师 - 类型定义
"""
from enum import Enum
from typing import Optional, List, Dict, Any
from dataclasses import dataclass, field
class HabitPhase(str, Enum):
INITIATION = "initiation" # 启动期
LEARNING = "learning" # 学习期
INTEGRATION = "integration" # 整合期
MAINTENANCE = "maintenance" # 维持期
MASTERY = "mastery" # 精通期
class HabitCategory(str, Enum):
PHYSICAL = "physical" # 身体活动
NUTRITION = "nutrition" # 营养饮食
SLEEP = "sleep" # 睡眠休息
MENTAL = "mental" # 心理健康
MINDFULNESS = "mindfulness" # 正念冥想
PRODUCTIVITY = "productivity" # 生产力
SOCIAL = "social" # 社交关系
LEARNING = "learning" # 学习成长
FINANCIAL = "financial" # 财务健康
ENVIRONMENTAL = "environmental" # 环境习惯
class HabitFrequency(str, Enum):
DAILY = "daily"
WEEKLY = "weekly"
BIWEEKLY = "biweekly"
MONTHLY = "monthly"
CUSTOM = "custom"
class CompletionStatus(str, Enum):
COMPLETED = "completed"
SKIPPED = "skipped"
PARTIAL = "partial"
FAILED = "failed"
class MotivationType(str, Enum):
INTRINSIC = "intrinsic" # 内在动机
EXTRINSIC = "extrinsic" # 外在动机
AUTONOMOUS = "autonomous" # 自主性
CONTROLLED = "controlled" # 控制性
@dataclass
class HabitRequest:
intent: str
habit: Optional[Dict[str, Any]] = None
habitId: Optional[str] = None
userContext: Optional[Dict[str, Any]] = None
feedback: Optional[Dict[str, Any]] = None
adjustment: Optional[Dict[str, Any]] = None
@dataclass
class HabitResponse:
success: bool
habitPlan: Optional[Dict[str, Any]] = None
evaluation: Optional[Dict[str, Any]] = None
checkInResult: Optional[Dict[str, Any]] = None
progressReport: Optional[Dict[str, Any]] = None
motivationAnalysis: Optional[Dict[str, Any]] = None
adjustmentSuggestion: Optional[Dict[str, Any]] = None
error: Optional[Dict[str, str]] = None
@dataclass
class DifficultyFactor:
factor: str
impact: int
controllability: float
mitigation: str
@dataclass
class MicroHabit:
id: str
habitId: str
name: str
description: str
action: str
timeRequired: int
level: int
isGateway: bool
intrinsicReward: str
extrinsicReward: Optional[str] = None
FILE:handler.py
"""
Health Habit Builder - Main Handler
健康习惯养成师 - 主逻辑入口
"""
import json
import uuid
from datetime import datetime, timedelta
from engine.types import HabitRequest, HabitResponse, HabitPhase
from engine.assessor import assess_habit_difficulty
from engine.microhabit import generate_microhabits
from engine.tracker import check_in, get_progress
from engine.motivator import analyze_motivation
# 模拟数据存储
_habits_db = {}
_checkins_db = {}
def handle_habit_request(request: dict) -> dict:
try:
intent = request.get("intent")
if intent == "create":
return _handle_create(request)
elif intent == "evaluate":
return _handle_evaluate(request)
elif intent == "checkIn":
return _handle_checkin(request)
elif intent == "progress":
return _handle_progress(request)
elif intent == "adjust":
return _handle_adjust(request)
elif intent == "motivate":
return _handle_motivate(request)
else:
return {
"success": False,
"error": {
"code": "INVALID_INTENT",
"message": f"未知的意图: {intent}"
}
}
except Exception as e:
return {
"success": False,
"error": {
"code": "INTERNAL_ERROR",
"message": str(e)
}
}
def _handle_create(request: dict) -> dict:
habit_info = request.get("habit", {})
user_context = request.get("userContext", {})
habit_id = f"habit_{uuid.uuid4().hex[:8]}"
habit_name = habit_info.get("name", "未命名习惯")
difficulty_result = assess_habit_difficulty(habit_name, user_context)
microhabits = generate_microhabits(habit_name, difficulty_result.get("overall", 5))
habit_plan = {
"id": habit_id,
"name": habit_name,
"description": habit_info.get("description", f"养成{habit_name}的健康习惯"),
"scientificBasis": {
"model": "Fogg行为模型 + 习惯循环理论",
"principles": [
"从小开始 - 微习惯不超过2分钟",
"锚点触发 - 关联现有习惯",
"即时奖励 - 完成后立即给予正向反馈",
"渐进式增加难度"
],
"evidence": "研究表明,66天的持续练习可使行为自动化的概率达到80%以上"
},
"difficultyAssessment": difficulty_result,
"microHabitProgression": microhabits,
"personalization": {
"recommendedTime": _recommend_time(habit_name),
"environmentSetup": _get_environment_setup(habit_name),
"reminderStrategy": {
"primary": "早晨7:00推送提醒",
"backup": "关联到现有习惯(如刷牙后)",
"content": "积极的启动语 + 进度提醒"
}
},
"motivationSystem": {
"intrinsicRewards": [
f"即时感受:完成{habit_name}后的成就感",
f"短期收益:当天的精神状态改善",
"长期价值:形成自动化行为,减轻意志力负担"
],
"extrinsicRewards": [
"完成7天:解锁新徽章",
"完成21天:获得习惯养成者称号",
"完成66天:习惯完全形成"
],
"accountability": {
"selfTracking": "每日打卡+简短反思",
"progressVisualization": "连续天数日历+完成率图表"
}
},
"troubleshootingGuide": {
"commonChallenges": [
{
"scenario": "忘记执行",
"solution": "设置双重提醒,关联到现有习惯",
"prevention": "选择固定的执行时间和地点"
},
{
"scenario": "动力不足",
"solution": "降低难度,重新从微习惯开始",
"prevention": "关注内在奖励而非外在奖励"
},
{
"scenario": "中断一天",
"solution": "不要自责,第二天继续即可",
"prevention": "设置预防机制,如提前准备"
}
]
}
}
_habits_db[habit_id] = {
"id": habit_id,
"name": habit_name,
"startDate": datetime.now().strftime("%Y-%m-%d"),
"currentPhase": "initiation",
"streak": {"current": 0, "longest": 0, "totalDays": 0},
"completionRate": 0,
"history": []
}
return {
"success": True,
"habitPlan": habit_plan
}
def _handle_evaluate(request: dict) -> dict:
habit_info = request.get("habit", {})
habit_name = habit_info.get("name", "")
user_context = request.get("userContext", {})
difficulty_result = assess_habit_difficulty(habit_name, user_context)
return {
"success": True,
"evaluation": {
"overallDifficulty": difficulty_result.get("overall", 5),
"successProbability": difficulty_result.get("successProbability", 0.5),
"barriers": _analyze_barriers(habit_name, user_context),
"recommendations": _generate_recommendations(habit_name, difficulty_result),
"estimatedTime": difficulty_result.get("estimatedFormationTime", 66)
}
}
def _handle_checkin(request: dict) -> dict:
habit_id = request.get("habitId", "")
feedback = request.get("feedback", {})
if habit_id not in _habits_db:
_habits_db[habit_id] = {
"id": habit_id,
"name": "模拟习惯",
"startDate": datetime.now().strftime("%Y-%m-%d"),
"currentPhase": "learning",
"streak": {"current": 5, "longest": 10, "totalDays": 15},
"completionRate": 85,
"history": []
}
habit = _habits_db[habit_id]
status = feedback.get("status", "completed")
if status == "completed":
habit["streak"]["current"] += 1
habit["streak"]["totalDays"] += 1
habit["streak"]["longest"] = max(habit["streak"]["longest"], habit["streak"]["current"])
message = f"太棒了!已连续完成 {habit['streak']['current']} 天"
elif status == "skipped":
habit["streak"]["current"] = 0
message = "今天跳过了,没关系,明天继续加油"
else:
habit["streak"]["current"] = 0
message = "部分完成,继续努力"
habit["history"].append({
"date": datetime.now().strftime("%Y-%m-%d"),
"status": status,
"quality": feedback.get("quality"),
"notes": feedback.get("notes", "")
})
return {
"success": True,
"checkInResult": {
"habitId": habit_id,
"date": datetime.now().strftime("%Y-%m-%d"),
"status": status,
"currentStreak": habit["streak"]["current"],
"longestStreak": habit["streak"]["longest"],
"totalCompleted": habit["streak"]["totalDays"],
"message": message
}
}
def _handle_progress(request: dict) -> dict:
habit_id = request.get("habitId", "")
if habit_id not in _habits_db:
_habits_db[habit_id] = {
"id": habit_id,
"name": "每日冥想",
"startDate": (datetime.now() - timedelta(days=21)).strftime("%Y-%m-%d"),
"currentPhase": "learning",
"streak": {"current": 7, "longest": 14, "totalDays": 21},
"completionRate": 85,
"history": [
{"date": (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d"), "status": "completed"}
for i in range(min(7, 21))
]
}
habit = _habits_db[habit_id]
recent_history = habit.get("history", [])
if not recent_history:
recent_history = [
{"date": (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d"), "status": "completed"}
for i in range(7)
]
return {
"success": True,
"progressReport": {
"habitId": habit_id,
"habitName": habit.get("name", "未知习惯"),
"currentPhase": _get_phase_name(habit.get("currentPhase", "initiation")),
"streak": habit["streak"],
"completionRate": habit.get("completionRate", 0),
"consistencyScore": int(habit["streak"]["longest"] / max(habit["streak"]["totalDays"], 1) * 100),
"recentHistory": recent_history[-10:],
"motivationLevel": {
"current": 75,
"trend": "stable"
},
"nextMilestone": {
"daysRemaining": max(0, 21 - habit["streak"]["totalDays"]),
"reward": "21天徽章"
}
}
}
def _handle_adjust(request: dict) -> dict:
habit_id = request.get("habitId", "")
adjustment = request.get("adjustment", {})
adjustment_type = adjustment.get("type", "difficulty")
description = adjustment.get("description", "")
suggestions = {
"goal": {
"type": "goal",
"changes": {
"frequency": "调整为每周4-5次而非每天",
"duration": "缩短单次时间至10分钟"
},
"reasoning": "降低门槛可以提高坚持概率",
"expectedOutcome": "完成率提升,动力增强"
},
"schedule": {
"type": "schedule",
"changes": {
"time": "从晚上改到早晨",
"trigger": "关联到起床后第一件事"
},
"reasoning": "早晨精力充沛,干扰更少",
"expectedOutcome": "完成质量提升"
},
"difficulty": {
"type": "difficulty",
"changes": {
"action": "拆分为更小的微习惯",
"simplicity": "从1分钟开始"
},
"reasoning": "降低阻力是维持习惯的关键",
"expectedOutcome": "更容易启动和坚持"
}
}
return {
"success": True,
"adjustmentSuggestion": suggestions.get(adjustment_type, suggestions["difficulty"])
}
def _handle_motivate(request: dict) -> dict:
habit_id = request.get("habitId", "")
return {
"success": True,
"motivationAnalysis": {
"levels": {
"intrinsic": 72,
"extrinsic": 45,
"overall": 65
},
"trend": "stable",
"positiveFactors": [
"已完成连续7天,信心增强",
"感受到睡眠质量改善",
"形成了一定的仪式感"
],
"negativeFactors": [
"偶尔感到枯燥",
"时间安排不够稳定"
],
"recommendations": [
{
"type": "boost",
"action": "尝试不同的时间或地点增加新鲜感",
"priority": "high"
},
{
"type": "sustain",
"action": "继续关注内在感受而非外在奖励",
"priority": "medium"
}
]
}
}
def _recommend_time(habit_name: str) -> str:
time_map = {
"冥想": "早晨起床后或晚上睡前",
"运动": "早晨7-8点或晚上6-7点",
"阅读": "睡前30分钟",
"写作": "早晨精力最充沛的时段",
"喝水": "全天分散,多设置提醒"
}
for key, time in time_map.items():
if key in habit_name:
return time
return "根据个人日程选择固定时间,建议早晨或晚上"
def _get_environment_setup(habit_name: str) -> list:
return [
"准备一个固定的位置/角落",
"移除干扰物(如手机静音)",
"准备好所需物品/装备",
"营造舒适的氛围(灯光、音乐等)"
]
def _analyze_barriers(habit_name: str, user_context: dict) -> list:
barriers = []
past_failures = user_context.get("pastFailures", "")
if "时间" in past_failures or "忙" in past_failures:
barriers.append({
"barrier": "时间不足",
"severity": "high",
"mitigation": "将习惯拆分为更小的微习惯,选择不被打扰的时段"
})
if "坚持" in past_failures or "懒" in past_failures:
barriers.append({
"barrier": "意志力不足",
"severity": "high",
"mitigation": "降低难度,关联到已有习惯,利用环境设计减少阻力"
})
if "忘记" in past_failures:
barriers.append({
"barrier": "容易忘记",
"severity": "medium",
"mitigation": "设置多重提醒,绑定到现有习惯作为触发器"
})
if not barriers:
barriers = [
{
"barrier": "启动阻力",
"severity": "medium",
"mitigation": "从极小的微习惯开始,如1分钟版本"
},
{
"barrier": "单调乏味",
"severity": "low",
"mitigation": "加入变化和奖励,保持新鲜感"
}
]
return barriers
def _generate_recommendations(habit_name: str, difficulty_result: dict) -> list:
difficulty = difficulty_result.get("overall", 5)
recommendations = []
if difficulty >= 7:
recommendations.append("建议从极简版本开始,如每天1分钟")
recommendations.append("先将这个习惯和已有习惯绑定")
if difficulty >= 5:
recommendations.append("设置提醒和奖励系统")
recommendations.append("找一个 accountability partner 互相监督")
recommendations.append("记录进度,保持可视化")
recommendations.append("允许偶尔跳过,但不要连续中断超过2天")
return recommendations
def _get_phase_name(phase: str) -> str:
phase_names = {
"initiation": "启动期",
"learning": "学习期",
"integration": "整合期",
"maintenance": "维持期",
"mastery": "精通期"
}
return phase_names.get(phase, "启动期")
if __name__ == "__main__":
print("=== Health Habit Builder 自测 ===")
print()
print("1. 测试创建习惯...")
create_request = {
"intent": "create",
"habit": {
"name": "每日冥想",
"description": "通过每日冥想培养正念,减少压力",
"frequency": "每天"
},
"userContext": {
"pastFailures": "之前尝试过但坚持不到一周",
"motivationType": "内在驱动"
}
}
result = handle_habit_request(create_request)
print(f"成功: {result.get('success')}")
if result.get("success"):
habit_plan = result.get("habitPlan", {})
print(f"习惯ID: {habit_plan.get('id')}")
print(f"难度评分: {habit_plan.get('difficultyAssessment', {}).get('overall')}")
print(f"成功概率: {habit_plan.get('difficultyAssessment', {}).get('successProbability')}")
print(f"预计形成时间: {habit_plan.get('difficultyAssessment', {}).get('estimatedFormationTime')}天")
print()
print("2. 测试评估习惯...")
eval_request = {
"intent": "evaluate",
"habit": {"name": "早起锻炼"},
"userContext": {"pastFailures": "太累了起不来"}
}
eval_result = handle_habit_request(eval_request)
print(f"成功: {eval_result.get('success')}")
if eval_result.get("success"):
eval_data = eval_result.get("evaluation", {})
print(f"难度: {eval_data.get('overallDifficulty')}")
print(f"建议: {eval_data.get('recommendations', [])[:2]}")
print()
print("3. 测试打卡...")
habit_id = result.get("habitPlan", {}).get("id", "test_habit_001") if result.get("success") else "test_habit_001"
checkin_request = {
"intent": "checkIn",
"habitId": habit_id,
"feedback": {
"status": "completed",
"quality": 8,
"notes": "感觉很好"
}
}
checkin_result = handle_habit_request(checkin_request)
print(f"成功: {checkin_result.get('success')}")
if checkin_result.get("success"):
print(f"连续天数: {checkin_result.get('checkInResult', {}).get('currentStreak')}")
print(f"消息: {checkin_result.get('checkInResult', {}).get('message')}")
print()
print("4. 测试进度查询...")
progress_request = {
"intent": "progress",
"habitId": habit_id
}
progress_result = handle_habit_request(progress_request)
print(f"成功: {progress_result.get('success')}")
if progress_result.get("success"):
report = progress_result.get("progressReport", {})
print(f"习惯名: {report.get('habitName')}")
print(f"当前阶段: {report.get('currentPhase')}")
print(f"连续: {report.get('streak', {}).get('current')}天")
print(f"完成率: {report.get('completionRate')}%")
print()
print("5. 测试动机分析...")
motivate_request = {
"intent": "motivate",
"habitId": habit_id
}
motivate_result = handle_habit_request(motivate_request)
print(f"成功: {motivate_result.get('success')}")
if motivate_result.get("success"):
analysis = motivate_result.get("motivationAnalysis", {})
print(f"总体动机: {analysis.get('levels', {}).get('overall')}")
print(f"趋势: {analysis.get('trend')}")
print(f"建议数量: {len(analysis.get('recommendations', []))}")
print()
print("=== 自测完成 ===")
FILE:package.json
{
"name": "health-habit-builder",
"version": "0.1.0",
"description": "Health Habit Builder - 健康习惯养成师",
"main": "handler.py",
"scripts": {
"test": "python3 handler.py"
},
"keywords": ["health", "habit", "wellness", "productivity", "mindfulness"],
"author": "harrylabsj",
"license": "MIT"
}
FILE:scripts/test_handler.py
#!/usr/bin/env python3
"""
Health Habit Builder - Test Script
健康习惯养成师 - 自测脚本
"""
import sys
import os
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from handler import handle_habit_request
def test_create():
print("\n1. 测试创建习惯...")
request = {
"intent": "create",
"habit": {
"name": "每日冥想",
"description": "通过每日冥想培养正念,减少压力",
"frequency": "每天"
},
"userContext": {
"pastFailures": "之前尝试过但坚持不到一周",
"motivationType": "内在驱动"
}
}
result = handle_habit_request(request)
print(f" 成功: {result.get('success')}")
if result.get("success"):
plan = result.get("habitPlan", {})
print(f" 习惯ID: {plan.get('id')}")
print(f" 难度评分: {plan.get('difficultyAssessment', {}).get('overall')}")
print(f" 成功概率: {plan.get('difficultyAssessment', {}).get('successProbability')}")
print(f" 预计形成时间: {plan.get('difficultyAssessment', {}).get('estimatedFormationTime')}天")
return plan.get("id")
return "test_habit_001"
def test_evaluate():
print("\n2. 测试评估习惯...")
request = {
"intent": "evaluate",
"habit": {"name": "早起锻炼"},
"userContext": {"pastFailures": "太累了起不来"}
}
result = handle_habit_request(request)
print(f" 成功: {result.get('success')}")
if result.get("success"):
eval_data = result.get("evaluation", {})
print(f" 难度: {eval_data.get('overallDifficulty')}")
print(f" 成功概率: {eval_data.get('successProbability')}")
print(f" 建议: {eval_data.get('recommendations', [])[:2]}")
def test_checkin(habit_id):
print("\n3. 测试打卡...")
request = {
"intent": "checkIn",
"habitId": habit_id,
"feedback": {
"status": "completed",
"quality": 8,
"notes": "感觉很好"
}
}
result = handle_habit_request(request)
print(f" 成功: {result.get('success')}")
if result.get("success"):
checkin = result.get("checkInResult", {})
print(f" 连续天数: {checkin.get('currentStreak')}")
print(f" 消息: {checkin.get('message')}")
def test_progress(habit_id):
print("\n4. 测试进度查询...")
request = {
"intent": "progress",
"habitId": habit_id
}
result = handle_habit_request(request)
print(f" 成功: {result.get('success')}")
if result.get("success"):
report = result.get("progressReport", {})
print(f" 习惯名: {report.get('habitName')}")
print(f" 当前阶段: {report.get('currentPhase')}")
print(f" 连续: {report.get('streak', {}).get('current')}天")
print(f" 完成率: {report.get('completionRate')}%")
def test_motivate(habit_id):
print("\n5. 测试动机分析...")
request = {
"intent": "motivate",
"habitId": habit_id
}
result = handle_habit_request(request)
print(f" 成功: {result.get('success')}")
if result.get("success"):
analysis = result.get("motivationAnalysis", {})
print(f" 总体动机: {analysis.get('levels', {}).get('overall')}")
print(f" 趋势: {analysis.get('trend')}")
print(f" 建议数量: {len(analysis.get('recommendations', []))}")
def test_adjust():
print("\n6. 测试习惯调整...")
request = {
"intent": "adjust",
"habitId": "test_habit_001",
"adjustment": {
"type": "difficulty",
"description": "感觉太难了"
}
}
result = handle_habit_request(request)
print(f" 成功: {result.get('success')}")
if result.get("success"):
suggestion = result.get("adjustmentSuggestion", {})
print(f" 调整类型: {suggestion.get('type')}")
print(f" 原因: {suggestion.get('reasoning')}")
def main():
print("=" * 50)
print("Health Habit Builder 自测")
print("=" * 50)
habit_id = test_create()
test_evaluate()
test_checkin(habit_id)
test_progress(habit_id)
test_motivate(habit_id)
test_adjust()
print("\n" + "=" * 50)
print("自测完成")
print("=" * 50)
if __name__ == "__main__":
main()
FILE:skill.json
{
"name": "health-habit-builder",
"version": "0.1.0",
"description": "Health Habit Builder / 健康习惯养成师. 基于行为科学和习惯形成理论,智能拆解微习惯、跟踪打卡、分析动机,帮助用户建立可持续的健康习惯。",
"keywords": ["health","habits","wellness","behavior","tracking"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/health-habit-builder",
"language": ["en","zh"],
"tags": ["health","habits","wellness","behavior","tracking"],
"createdAt": "2026-04-06",
"updatedAt": "2026-04-06"
}
个性化学习路径导航仪 - 根据学习目标、当前水平和可用资源生成定制化学习计划。 支持技能评估、路径规划、资源推荐、里程碑跟踪等功能。
---
name: learning-path-navigator
slug: learning-path-navigator
version: 0.1.0
description: |
个性化学习路径导航仪 - 根据学习目标、当前水平和可用资源生成定制化学习计划。
支持技能评估、路径规划、资源推荐、里程碑跟踪等功能。
---
# Learning Path Navigator / 学习路径导航仪
你是**个性化学习路径导航仪**。
你的任务是分析用户的学习目标、当前水平和可用资源,生成科学的、可执行的个性化学习路径,帮助用户高效达成学习目标。
## 产品定位
Learning Path Navigator 不只是课程推荐,而是完整的**从目标到成果的学习生态系统**:
- **知识图谱评估**:构建用户当前知识状态的动态图谱
- **路径规划**:基于目标生成最优学习序列
- **资源推荐**:为每个学习阶段匹配最佳学习资源
- **里程碑跟踪**:定义和跟踪关键学习节点
## 使用场景
用户可能会说:
- "帮我制定一个Python学习计划"
- "3个月学会数据分析,规划一下"
- "我想转行做机器学习,该怎么学"
- "制定一个前端开发的学习路线"
- "评估一下我的Java水平"
- "推荐一些数据可视化的学习资源"
## 输入格式
### 格式1:目标导向指令
```
学习 [技能/领域] 达到 [水平] 在 [时间] 内
```
示例:
- `学习 Python 达到 中级 在 3个月 内`
- `学习 数据分析 达到 高级 在 6个月 内`
### 格式2:详细规划指令
```
创建学习路径:
目标:[具体目标描述]
当前水平:[自我评估或测试结果]
可用时间:[每周小时数] [总周数]
偏好:[学习风格/资源类型]
预算:[预算限制]
```
示例:
```
创建学习路径:
目标:掌握机器学习基础,能完成简单的预测模型
当前水平:Python中级,数学基础一般
可用时间:每周10小时,共12周
偏好:视频教程+实践项目
预算:500元以内
```
### 格式3:评估与资源指令
```
评估我的 [技能] 水平
推荐 [技能] 学习资源
```
## 输入 schema(统一需求格式)
```typescript
interface LearningRequest {
goal?: {
description: string; // 学习目标描述
skills?: string[]; // 具体技能列表
targetLevel?: 'beginner' | 'intermediate' | 'advanced' | 'expert';
timeframe?: {
totalWeeks?: number; // 总周数
hoursPerWeek?: number; // 每周小时数
deadline?: string; // 截止日期
};
};
currentLevel?: {
skills?: { skillId: string; level: number }[];
selfAssessment?: string;
testResults?: string;
};
constraints?: {
budget?: number;
preferredFormats?: ResourceType[];
learningStyle?: 'visual' | 'auditory' | 'kinesthetic' | 'reading' | 'mixed';
languages?: string[];
};
}
type ResourceType = 'course' | 'book' | 'video' | 'tutorial' | 'article' | 'podcast' | 'exercise' | 'project' | 'cheatsheet' | 'community';
```
## 输出 schema(统一学习路径报告)
```typescript
interface LearningPathResponse {
success: boolean;
learningPath?: {
id: string;
title: string;
goal: {
description: string;
targetSkills: { skill: string; targetLevel: string }[];
};
phases: LearningPhase[];
milestones: Milestone[];
resources: {
primary: PhaseResource[];
supplementary: { category: string; items: string[] }[];
};
progressTracking: {
currentPhase: number;
overallProgress: string;
estimatedCompletion: string;
weeklyCheckpoints: { week: number; date: string; checkpoint: string }[];
};
adaptiveFeatures: {
difficultyAdjustment: string;
resourceRecommendation: string;
scheduleFlexibility: string;
};
};
recommendations?: string[];
nextSteps?: string[];
error?: string;
}
```
## 输出示例
```json
{
"success": true,
"learningPath": {
"id": "path_xxx",
"title": "Python数据分析专家之路 - 12周计划",
"goal": {
"description": "掌握Python数据分析核心技能",
"targetSkills": [
{"skill": "Python", "targetLevel": "intermediate"},
{"skill": "Pandas", "targetLevel": "intermediate"}
]
},
"phases": [
{
"id": "phase_1",
"phaseNumber": 1,
"title": "阶段1:Python基础与数据处理入门",
"duration": {
"weeks": 3,
"totalHours": 30,
"weeklyBreakdown": [...]
},
"objectives": ["掌握核心概念", "完成实践练习", "通过评估测试"],
"resources": [...],
"successCriteria": {
"knowledgeCheck": ["完成学习", "测验≥80分"],
"minimumScores": {"quizzes": 80, "projects": 75, "overall": 78}
}
}
],
"milestones": [
{
"id": "milestone_1",
"title": "第一阶段完成",
"scheduledDate": "2026-04-26",
"requirements": ["完成学习", "测验≥80分", "提交项目"],
"reward": "解锁第二阶段内容"
}
],
"progressTracking": {
"currentPhase": 1,
"overallProgress": "0%",
"estimatedCompletion": "2026-06-28",
"weeklyCheckpoints": [
{"week": 1, "date": "2026-04-12", "checkpoint": "基础语法掌握"}
]
}
},
"recommendations": [
"建议每天固定时间学习,保持连续性",
"加入学习社群获取同伴支持",
"定期回顾和调整学习计划"
],
"nextSteps": [
"确认并开始第一阶段学习",
"设置每周学习提醒",
"加入推荐的学习社区"
]
}
```
## 触发词
- `学习路径导航`
- `制定学习计划`
- `学习路线图`
- `技能提升规划`
- `个性化学习路径`
- `评估我的水平`
- `推荐学习资源`
## 注意事项
- 当前版本为模拟数据版本,资源推荐基于典型场景生成
- 真实资源推荐需要接入外部教育平台 API
- 进度跟踪功能需要用户持续更新学习状态
- 自适应调整基于阶段性评估结果触发
FILE:clawhub.json
{
"name": "learning-path-navigator",
"slug": "learning-path-navigator",
"version": "0.1.0",
"description": "个性化学习路径导航仪 - 根据学习目标、当前水平和可用资源生成定制化学习计划",
"keywords": ["learning-path", "study-plan", "skill-development", "education", "personalized-learning"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/learning-path-navigator",
"language": ["en", "zh"],
"tags": ["learning", "education", "study-plan", "skill", "personalized"],
"createdAt": "2026-04-05",
"updatedAt": "2026-04-05"
}
FILE:engine/router.js
// Learning Path Navigator - 路由层
// 负责接收学习请求,生成个性化学习路径
// 生成唯一ID
function generateId(prefix) {
return prefix + '_' + Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 6);
}
// 计算阶段数量
function calculatePhaseCount(totalWeeks) {
if (totalWeeks <= 4) return 1;
if (totalWeeks <= 8) return 2;
if (totalWeeks <= 16) return 3;
return Math.ceil(totalWeeks / 4);
}
// 生成学习阶段
function generatePhases(request, totalWeeks, hoursPerWeek) {
const phases = [];
const phaseCount = calculatePhaseCount(totalWeeks);
const weeksPerPhase = Math.ceil(totalWeeks / phaseCount);
const skills = request.goal?.skills || ['相关技能'];
const targetLevel = request.goal?.targetLevel || 'intermediate';
const phaseTitles = [
'基础概念与入门',
'核心知识与实践',
'进阶应用与项目',
'专项深化与拓展',
];
for (let i = 0; i < phaseCount; i++) {
const phaseWeeks = Math.min(weeksPerPhase, totalWeeks - i * weeksPerPhase);
const phaseHours = hoursPerWeek * phaseWeeks;
// 生成每周计划
const weeklyBreakdown = [];
for (let w = 0; w < phaseWeeks; w++) {
weeklyBreakdown.push({
week: i * weeksPerPhase + w + 1,
focus: `第w + 1周:['基础夯实', '技能提升', '综合应用', '项目实战'][w % 4]`,
hours: hoursPerWeek,
resources: [
{ type: 'course', title: `skills[0] || '该技能'系统课程`, duration: '4小时', format: 'interactive' },
{ type: 'exercise', title: '实践练习题', duration: '3小时', format: 'interactive' },
{ type: 'video', title: '配套视频教程', duration: '2小时', format: 'video' },
{ type: 'article', title: '参考资料阅读', duration: '1小时', format: 'text' },
],
milestones: w === phaseWeeks - 1 ? [
{ title: '阶段测验', passingScore: 80, deadline: `第i * weeksPerPhase + w + 1周末` }
] : [],
});
}
// 生成阶段资源
const phaseResources = [
{
resourceId: generateId('res'),
type: 'course',
title: `phaseTitles[i] - 系统课程`,
description: `完成phaseTitles[i]的全部内容学习`,
duration: `Math.round(phaseHours * 0.4)小时`,
format: 'online',
qualityScore: 4.5,
completionRate: 92,
cost: '免费',
},
{
resourceId: generateId('res'),
type: 'project',
title: `phaseTitles[i]实战项目`,
description: '完成一个综合性实践项目',
duration: `Math.round(phaseHours * 0.3)小时`,
format: 'interactive',
qualityScore: 4.7,
completionRate: 85,
cost: '免费',
},
{
resourceId: generateId('res'),
type: 'book',
title: '推荐参考书籍',
description: '深入学习的补充材料',
duration: `Math.round(phaseHours * 0.2)小时`,
format: 'text',
qualityScore: 4.3,
completionRate: 60,
cost: '¥30-80',
},
];
phases.push({
id: generateId('phase'),
phaseNumber: i + 1,
title: `阶段i + 1:phaseTitles[i] || '学习阶段' + (i + 1)`,
description: `本阶段将完成phaseTitles[i],为后续学习打下坚实基础`,
duration: {
weeks: phaseWeeks,
totalHours: phaseHours,
weeklyBreakdown,
},
objectives: [
'掌握核心概念和基本原理',
'完成配套实践练习',
'通过阶段评估测试',
],
skillsCovered: skills.slice(0, Math.ceil(skills.length / phaseCount) * (i + 1)),
resources: phaseResources,
assessments: [
{
id: generateId('assess'),
name: '阶段综合测验',
type: 'quiz',
passingScore: 80,
},
{
id: generateId('assess'),
name: '实践项目评估',
type: 'project',
passingScore: 75,
},
],
successCriteria: {
knowledgeCheck: ['完成所有章节学习', '测验得分≥80分'],
practicalProjects: ['完成实战项目', '项目评分≥75分'],
minimumScores: { quizzes: 80, projects: 75, overall: 78 },
},
});
}
return phases;
}
// 生成里程碑
function generateMilestones(phases, startDate, totalWeeks) {
const milestones = [];
phases.forEach((phase, idx) => {
const phaseStartWeek = idx === 0 ? 0 : phases.slice(0, idx).reduce((sum, p) => sum + p.duration.weeks, 0);
const milestoneWeek = phaseStartWeek + phase.duration.weeks;
const milestoneDate = new Date(startDate);
milestoneDate.setDate(milestoneDate.getDate() + milestoneWeek * 7);
milestones.push({
id: generateId('milestone'),
title: `phase.title完成`,
description: `完成phase.title所有内容,达到阶段学习目标`,
phaseId: phase.id,
scheduledDate: milestoneDate.toISOString().split('T')[0],
requirements: [
'完成所有阶段资源学习',
`通过阶段测验(≥phase.successCriteria.minimumScores.quizzes分)`,
'提交实践项目',
],
passingScore: phase.successCriteria.minimumScores.overall,
status: 'pending',
reward: idx < phases.length - 1 ? '解锁下一阶段内容+学习资料包' : '获得结业证书',
});
});
return milestones;
}
// 生成补充资源
function generateSupplementaryResources(skills) {
return [
{
category: '参考书籍',
items: [
'《深入理解XX技能》',
'《实战指南》',
'《高级特性详解》',
],
},
{
category: '练习平台',
items: [
'LeetCode / 专项练习',
'GitHub开源项目',
'在线编程实验室',
],
},
{
category: '社区资源',
items: [
'官方文档',
'技术博客',
'学习交流群',
],
},
];
}
// 生成每周检查点
function generateWeeklyCheckpoints(totalWeeks, startDate) {
const checkpoints = [];
const checkpointWeeks = [1, 2, 4, 8, 12, 16].filter(w => w <= totalWeeks);
checkpointWeeks.forEach(week => {
const date = new Date(startDate);
date.setDate(date.getDate() + (week - 1) * 7);
checkpoints.push({
week,
date: date.toISOString().split('T')[0],
checkpoint: `第week周学习成果检验`,
});
});
return checkpoints;
}
// 从描述中提取技能
function extractSkills(description) {
const commonSkills = [
'Python', 'JavaScript', 'Java', 'Go', 'Rust', 'C++',
'数据分析', '机器学习', '深度学习', '人工智能',
'前端开发', '后端开发', '全栈开发', '移动开发',
'数据结构', '算法', '数据库', '云计算', 'DevOps',
];
return commonSkills.filter(skill =>
description.toLowerCase().includes(skill.toLowerCase())
).slice(0, 5) || ['综合技能'];
}
// 主路由函数
async function runDecisionEngine(request) {
try {
const goalDesc = request.goal?.description || '提升相关技能';
const skills = request.goal?.skills || extractSkills(goalDesc);
const targetLevel = request.goal?.targetLevel || 'intermediate';
const totalWeeks = request.goal?.timeframe?.totalWeeks || 12;
const hoursPerWeek = request.goal?.timeframe?.hoursPerWeek || 10;
const startDate = new Date();
const completionDate = new Date(startDate);
completionDate.setDate(completionDate.getDate() + totalWeeks * 7);
const phases = generatePhases(request, totalWeeks, hoursPerWeek);
const milestones = generateMilestones(phases, startDate, totalWeeks);
const learningPath = {
id: generateId('path'),
title: `skills[0] || '技能提升'专家之路 - totalWeeks周计划`,
goal: {
description: goalDesc,
targetSkills: skills.map(s => ({ skill: s, targetLevel: targetLevel })),
},
phases,
milestones,
resources: {
primary: phases.flatMap(p => p.resources),
supplementary: generateSupplementaryResources(skills),
},
progressTracking: {
currentPhase: 1,
overallProgress: '0%',
estimatedCompletion: completionDate.toISOString().split('T')[0],
weeklyCheckpoints: generateWeeklyCheckpoints(totalWeeks, startDate),
},
adaptiveFeatures: {
difficultyAdjustment: '基于测验成绩自动调整下一阶段难度',
resourceRecommendation: '根据学习风格和进度个性化推荐',
scheduleFlexibility: '允许±2周时间缓冲,可根据实际情况调整',
},
};
return {
success: true,
learningPath,
recommendations: [
'建议每天固定时间学习,保持学习的连续性',
'每周预留2-3小时用于复习和练习',
'加入学习社群获取同伴支持和答疑',
'定期记录学习笔记和心得体会',
],
nextSteps: [
'确认并开始第一阶段学习',
'设置每周学习提醒',
'加入推荐的学习社区',
'准备第一周所需的学习资源',
],
};
} catch (error) {
return {
success: false,
error: '生成学习路径失败: ' + (error.message || '未知错误'),
};
}
}
export { runDecisionEngine };
FILE:engine/router.ts
// Learning Path Navigator - 路由层
// 负责接收学习请求,生成个性化学习路径
import type { LearningRequest, LearningPathResponse, LearningPath, LearningPhase, PhaseResource, Milestone, WeeklyPlan } from "./types";
// 生成唯一ID
function generateId(prefix: string): string {
return `prefix_Date.now().toString(36)_Math.random().toString(36).substr(2, 6)`;
}
// 计算目标熟练度对应的数值
function levelToScore(level: string): number {
const map: Record<string, number> = {
'beginner': 30,
'intermediate': 70,
'advanced': 90,
'expert': 98,
};
return map[level] || 50;
}
// 计算阶段数量
function calculatePhaseCount(totalWeeks: number): number {
if (totalWeeks <= 4) return 1;
if (totalWeeks <= 8) return 2;
if (totalWeeks <= 16) return 3;
return Math.ceil(totalWeeks / 4);
}
// 生成学习阶段
function generatePhases(request: LearningRequest, totalWeeks: number, hoursPerWeek: number): LearningPhase[] {
const phases: LearningPhase[] = [];
const phaseCount = calculatePhaseCount(totalWeeks);
const weeksPerPhase = Math.ceil(totalWeeks / phaseCount);
const hoursPerPhase = hoursPerWeek * weeksPerPhase;
const skills = request.goal?.skills || ['相关技能'];
const targetLevel = request.goal?.targetLevel || 'intermediate';
const phaseTitles = [
'基础概念与入门',
'核心知识与实践',
'进阶应用与项目',
'专项深化与拓展',
];
for (let i = 0; i < phaseCount; i++) {
const phaseWeeks = Math.min(weeksPerPhase, totalWeeks - i * weeksPerPhase);
const phaseHours = hoursPerWeek * phaseWeeks;
// 生成每周计划
const weeklyBreakdown: WeeklyPlan[] = [];
for (let w = 0; w < phaseWeeks; w++) {
weeklyBreakdown.push({
week: i * weeksPerPhase + w + 1,
focus: `第w + 1周:['基础夯实', '技能提升', '综合应用', '项目实战'][w % 4]`,
hours: hoursPerWeek,
resources: [
{ type: 'course', title: `skills[0] || '该技能'系统课程`, duration: '4小时', format: 'interactive' },
{ type: 'exercise', title: '实践练习题', duration: '3小时', format: 'interactive' },
{ type: 'video', title: '配套视频教程', duration: '2小时', format: 'video' },
{ type: 'article', title: '参考资料阅读', duration: '1小时', format: 'text' },
],
milestones: w === phaseWeeks - 1 ? [
{ title: '阶段测验', passingScore: 80, deadline: `第i * weeksPerPhase + w + 1周末` }
] : [],
});
}
// 生成阶段资源
const phaseResources: PhaseResource[] = [
{
resourceId: generateId('res'),
type: 'course',
title: `phaseTitles[i] - 系统课程`,
description: `完成phaseTitles[i]的全部内容学习`,
duration: `Math.round(phaseHours * 0.4)小时`,
format: 'online',
qualityScore: 4.5,
completionRate: 92,
cost: '免费',
},
{
resourceId: generateId('res'),
type: 'project',
title: `phaseTitles[i]实战项目`,
description: '完成一个综合性实践项目',
duration: `Math.round(phaseHours * 0.3)小时`,
format: 'interactive',
qualityScore: 4.7,
completionRate: 85,
cost: '免费',
},
{
resourceId: generateId('res'),
type: 'book',
title: '推荐参考书籍',
description: '深入学习的补充材料',
duration: `Math.round(phaseHours * 0.2)小时`,
format: 'text',
qualityScore: 4.3,
completionRate: 60,
cost: '¥30-80',
},
];
phases.push({
id: generateId('phase'),
phaseNumber: i + 1,
title: `阶段i + 1:phaseTitles[i] || `学习阶段${i + 1`}`,
description: `本阶段将完成phaseTitles[i],为后续学习打下坚实基础`,
duration: {
weeks: phaseWeeks,
totalHours: phaseHours,
weeklyBreakdown,
},
objectives: [
'掌握核心概念和基本原理',
'完成配套实践练习',
'通过阶段评估测试',
],
skillsCovered: skills.slice(0, Math.ceil(skills.length / phaseCount) * (i + 1)),
resources: phaseResources,
assessments: [
{
id: generateId('assess'),
name: '阶段综合测验',
type: 'quiz',
passingScore: 80,
},
{
id: generateId('assess'),
name: '实践项目评估',
type: 'project',
passingScore: 75,
},
],
successCriteria: {
knowledgeCheck: ['完成所有章节学习', '测验得分≥80分'],
practicalProjects: ['完成实战项目', '项目评分≥75分'],
minimumScores: { quizzes: 80, projects: 75, overall: 78 },
},
});
}
return phases;
}
// 生成里程碑
function generateMilestones(phases: LearningPhase[], startDate: Date, totalWeeks: number): Milestone[] {
const milestones: Milestone[] = [];
phases.forEach((phase, idx) => {
const phaseStartWeek = idx === 0 ? 0 : phases.slice(0, idx).reduce((sum, p) => sum + p.duration.weeks, 0);
const milestoneWeek = phaseStartWeek + phase.duration.weeks;
const milestoneDate = new Date(startDate);
milestoneDate.setDate(milestoneDate.getDate() + milestoneWeek * 7);
milestones.push({
id: generateId('milestone'),
title: `phase.title完成`,
description: `完成phase.title所有内容,达到阶段学习目标`,
phaseId: phase.id,
scheduledDate: milestoneDate.toISOString().split('T')[0],
requirements: [
'完成所有阶段资源学习',
`通过阶段测验(≥phase.successCriteria.minimumScores.quizzes分)`,
'提交实践项目',
],
passingScore: phase.successCriteria.minimumScores.overall,
status: 'pending',
reward: idx < phases.length - 1 ? `解锁下一阶段内容+学习资料包` : '获得结业证书',
});
});
return milestones;
}
// 生成补充资源
function generateSupplementaryResources(skills: string[]): { category: string; items: string[] }[] {
return [
{
category: '参考书籍',
items: [
'《深入理解XX技能》',
'《实战指南》',
'《高级特性详解》',
],
},
{
category: '练习平台',
items: [
'LeetCode / 专项练习',
'GitHub开源项目',
'在线编程实验室',
],
},
{
category: '社区资源',
items: [
'官方文档',
'技术博客',
'学习交流群',
],
},
];
}
// 生成每周检查点
function generateWeeklyCheckpoints(totalWeeks: number, startDate: Date): { week: number; date: string; checkpoint: string }[] {
const checkpoints: { week: number; date: string; checkpoint: string }[] = [];
const checkpointWeeks = [1, 2, 4, 8, 12, 16].filter(w => w <= totalWeeks);
checkpointWeeks.forEach(week => {
const date = new Date(startDate);
date.setDate(date.getDate() + (week - 1) * 7);
checkpoints.push({
week,
date: date.toISOString().split('T')[0],
checkpoint: `第week周学习成果检验`,
});
});
return checkpoints;
}
// 主路由函数
export async function runDecisionEngine(request: LearningRequest): Promise<LearningPathResponse> {
try {
// 解析请求参数
const goalDesc = request.goal?.description || '提升相关技能';
const skills = request.goal?.skills || extractSkills(goalDesc);
const targetLevel = request.goal?.targetLevel || 'intermediate';
const totalWeeks = request.goal?.timeframe?.totalWeeks || 12;
const hoursPerWeek = request.goal?.timeframe?.hoursPerWeek || 10;
// 计算完成日期
const startDate = new Date();
const completionDate = new Date(startDate);
completionDate.setDate(completionDate.getDate() + totalWeeks * 7);
// 生成学习阶段
const phases = generatePhases(request, totalWeeks, hoursPerWeek);
// 生成里程碑
const milestones = generateMilestones(phases, startDate, totalWeeks);
// 生成学习路径
const learningPath: LearningPath = {
id: generateId('path'),
title: `skills[0] || '技能提升'专家之路 - totalWeeks周计划`,
goal: {
description: goalDesc,
targetSkills: skills.map(s => ({ skill: s, targetLevel: targetLevel })),
},
phases,
milestones,
resources: {
primary: phases.flatMap(p => p.resources),
supplementary: generateSupplementaryResources(skills),
},
progressTracking: {
currentPhase: 1,
overallProgress: '0%',
estimatedCompletion: completionDate.toISOString().split('T')[0],
weeklyCheckpoints: generateWeeklyCheckpoints(totalWeeks, startDate),
},
adaptiveFeatures: {
difficultyAdjustment: '基于测验成绩自动调整下一阶段难度',
resourceRecommendation: '根据学习风格和进度个性化推荐',
scheduleFlexibility: '允许±2周时间缓冲,可根据实际情况调整',
},
};
return {
success: true,
learningPath,
recommendations: [
'建议每天固定时间学习,保持学习的连续性',
'每周预留2-3小时用于复习和练习',
'加入学习社群获取同伴支持和答疑',
'定期记录学习笔记和心得体会',
],
nextSteps: [
'确认并开始第一阶段学习',
'设置每周学习提醒',
'加入推荐的学习社区',
'准备第一周所需的学习资源',
],
};
} catch (error) {
return {
success: false,
error: `生成学习路径失败: '未知错误'`,
};
}
}
// 从描述中提取技能
function extractSkills(description: string): string[] {
// 简单的技能提取逻辑
const commonSkills = [
'Python', 'JavaScript', 'Java', 'Go', 'Rust', 'C++',
'数据分析', '机器学习', '深度学习', '人工智能',
'前端开发', '后端开发', '全栈开发', '移动开发',
'数据结构', '算法', '数据库', '云计算', 'DevOps',
];
return commonSkills.filter(skill =>
description.toLowerCase().includes(skill.toLowerCase())
).slice(0, 5) || ['综合技能'];
}
FILE:engine/types.ts
// Learning Path Navigator 类型定义
export type ProficiencyLevel = 'beginner' | 'intermediate' | 'advanced' | 'expert';
export type ResourceType = 'course' | 'book' | 'video' | 'tutorial' | 'article' | 'podcast' | 'exercise' | 'project' | 'cheatsheet' | 'community';
export type LearningStyle = 'visual' | 'auditory' | 'kinesthetic' | 'reading' | 'mixed';
export type DifficultyLevel = 'beginner' | 'intermediate' | 'advanced' | 'expert';
export type ProgressStatus = 'not-started' | 'in-progress' | 'completed' | 'paused' | 'abandoned';
// 学习目标
export interface LearningGoal {
id: string;
title: string;
description: string;
skills: SkillRequirement[];
targetLevel: ProficiencyLevel;
timeframe: {
totalWeeks: number;
hoursPerWeek: number;
deadline?: string;
};
constraints: {
budget?: number;
preferredFormats: ResourceType[];
languages?: string[];
};
}
// 技能要求
export interface SkillRequirement {
skillId: string;
skillName: string;
targetProficiency: number;
priority: 'high' | 'medium' | 'low';
dependencies: string[];
}
// 用户当前水平
export interface CurrentProficiency {
skills: {
skillId: string;
selfAssessment: number;
testScore?: number;
confidence: number;
}[];
learningStyle: {
visual: number;
auditory: number;
kinesthetic: number;
reading: number;
};
constraints: {
availableHours: number;
preferredTimes?: string[];
maxSessionLength?: number;
};
}
// 学习请求
export interface LearningRequest {
goal?: {
description: string;
skills?: string[];
targetLevel?: ProficiencyLevel;
timeframe?: {
totalWeeks?: number;
hoursPerWeek?: number;
deadline?: string;
};
};
currentLevel?: {
skills?: { skillId: string; level: number }[];
selfAssessment?: string;
testResults?: string;
};
constraints?: {
budget?: number;
preferredFormats?: ResourceType[];
learningStyle?: LearningStyle;
languages?: string[];
};
}
// 学习阶段
export interface LearningPhase {
id: string;
phaseNumber: number;
title: string;
description: string;
duration: {
weeks: number;
totalHours: number;
weeklyBreakdown: WeeklyPlan[];
};
objectives: string[];
skillsCovered: string[];
resources: PhaseResource[];
assessments: AssessmentPlan[];
successCriteria: {
knowledgeCheck: string[];
practicalProjects: string[];
minimumScores: {
quizzes: number;
projects: number;
overall: number;
};
};
}
// 每周计划
export interface WeeklyPlan {
week: number;
focus: string;
hours: number;
resources: ResourceSummary[];
milestones: MilestoneSummary[];
}
// 资源摘要
export interface ResourceSummary {
type: ResourceType;
title: string;
url?: string;
duration: string;
format: string;
}
// 里程碑摘要
export interface MilestoneSummary {
title: string;
passingScore?: number;
deadline?: string;
}
// 阶段资源
export interface PhaseResource {
resourceId: string;
type: ResourceType;
title: string;
url?: string;
description: string;
duration: string;
format: string;
qualityScore?: number;
completionRate?: number;
cost: string;
}
// 评估计划
export interface AssessmentPlan {
id: string;
name: string;
type: 'quiz' | 'project' | 'presentation' | 'peer-review' | 'self-assessment';
scheduledDate?: string;
passingScore: number;
}
// 里程碑
export interface Milestone {
id: string;
title: string;
description: string;
phaseId: string;
scheduledDate: string;
requirements: string[];
passingScore?: number;
status: 'pending' | 'in-progress' | 'completed' | 'failed';
reward?: string;
}
// 学习路径
export interface LearningPath {
id: string;
title: string;
goal: {
description: string;
targetSkills: { skill: string; targetLevel: string }[];
};
phases: LearningPhase[];
milestones: Milestone[];
resources: {
primary: PhaseResource[];
supplementary: { category: string; items: string[] }[];
};
progressTracking: {
currentPhase: number;
overallProgress: string;
estimatedCompletion: string;
weeklyCheckpoints: { week: number; date: string; checkpoint: string }[];
};
adaptiveFeatures: {
difficultyAdjustment: string;
resourceRecommendation: string;
scheduleFlexibility: string;
};
}
// 学习路径响应
export interface LearningPathResponse {
success: boolean;
learningPath?: LearningPath;
recommendations?: string[];
nextSteps?: string[];
error?: string;
}
FILE:handler.js
// Learning Path Navigator - Handler
// 主入口文件,处理用户请求并返回学习路径
import { runDecisionEngine } from './engine/router.js';
// 导出主函数供外部调用
export async function handleLearningRequest(request) {
return await runDecisionEngine(request);
}
// CLI 测试入口
async function main() {
const testRequest = {
goal: {
description: '掌握Python数据分析技能,能够独立完成数据清洗、分析和可视化项目',
skills: ['Python', 'Pandas', 'NumPy', '数据可视化', '统计分析'],
targetLevel: 'intermediate',
timeframe: {
totalWeeks: 12,
hoursPerWeek: 10,
},
},
constraints: {
budget: 500,
preferredFormats: ['course', 'video', 'exercise'],
learningStyle: 'visual',
},
};
console.log('🧭 学习路径导航仪 - 自测开始\n');
console.log('输入请求:', JSON.stringify(testRequest, null, 2));
console.log('\n---\n');
const result = await handleLearningRequest(testRequest);
if (result.success && result.learningPath) {
const path = result.learningPath;
console.log('✅ 学习路径生成成功!\n');
console.log(`📚 path.title`);
console.log(`🎯 目标: path.goal.description`);
console.log(`📅 预计完成: path.progressTracking.estimatedCompletion`);
console.log(`\n📈 路径阶段 (共path.phases.length个阶段):`);
path.phases.forEach(phase => {
console.log(`\n 阶段phase.phaseNumber: phase.title`);
console.log(` 时长: phase.duration.weeks周, phase.duration.totalHours小时`);
console.log(` 目标: phase.objectives.join('; ')`);
console.log(` 资源: phase.resources.length个`);
});
console.log(`\n🏆 里程碑 (共path.milestones.length个):`);
path.milestones.forEach(m => {
console.log(` - m.title (m.scheduledDate)`);
});
console.log(`\n💡 建议:`);
result.recommendations?.forEach(r => console.log(` • r`));
console.log(`\n📌 下一步:`);
result.nextSteps?.forEach(s => console.log(` → s`));
} else {
console.log('❌ 生成失败:', result.error);
}
console.log('\n---\n自测完成');
return result;
}
// 运行自测
main().catch(console.error);
FILE:handler.ts
// Learning Path Navigator - Handler
// 主入口文件,处理用户请求并返回学习路径
import { runDecisionEngine } from './engine/router.js';
import type { LearningRequest, LearningPathResponse } from './engine/types.js';
// 导出主函数供外部调用
export async function handleLearningRequest(request: LearningRequest): Promise<LearningPathResponse> {
return await runDecisionEngine(request);
}
// CLI 测试入口
async function main() {
const testRequest: LearningRequest = {
goal: {
description: '掌握Python数据分析技能,能够独立完成数据清洗、分析和可视化项目',
skills: ['Python', 'Pandas', 'NumPy', '数据可视化', '统计分析'],
targetLevel: 'intermediate',
timeframe: {
totalWeeks: 12,
hoursPerWeek: 10,
},
},
constraints: {
budget: 500,
preferredFormats: ['course', 'video', 'exercise'],
learningStyle: 'visual',
},
};
console.log('🧭 学习路径导航仪 - 自测开始\n');
console.log('输入请求:', JSON.stringify(testRequest, null, 2));
console.log('\n---\n');
const result = await handleLearningRequest(testRequest);
if (result.success && result.learningPath) {
const path = result.learningPath;
console.log('✅ 学习路径生成成功!\n');
console.log(`📚 path.title`);
console.log(`🎯 目标: path.goal.description`);
console.log(`📅 预计完成: path.progressTracking.estimatedCompletion`);
console.log(`\n📈 路径阶段 (共path.phases.length个阶段):`);
path.phases.forEach(phase => {
console.log(`\n 阶段phase.phaseNumber: phase.title`);
console.log(` 时长: phase.duration.weeks周, phase.duration.totalHours小时`);
console.log(` 目标: phase.objectives.join('; ')`);
console.log(` 资源: phase.resources.length个`);
});
console.log(`\n🏆 里程碑 (共path.milestones.length个):`);
path.milestones.forEach(m => {
console.log(` - m.title (m.scheduledDate)`);
});
console.log(`\n💡 建议:`);
result.recommendations?.forEach(r => console.log(` • r`));
console.log(`\n📌 下一步:`);
result.nextSteps?.forEach(s => console.log(` → s`));
} else {
console.log('❌ 生成失败:', result.error);
}
console.log('\n---\n自测完成');
return result;
}
// 运行自测
main().catch(console.error);
FILE:package.json
{
"name": "learning-path-navigator",
"version": "0.1.0",
"description": "个性化学习路径导航仪 - 根据学习目标、当前水平和可用资源生成定制化学习计划",
"type": "module",
"main": "handler.js",
"scripts": {
"test": "node handler.js"
},
"keywords": ["learning", "education", "study-plan", "skill-development", "personalized-learning"],
"author": "harrylabsj",
"license": "MIT"
}
FILE:skill.json
{
"name": "learning-path-navigator",
"version": "0.1.0",
"description": "个性化学习路径导航仪 - 根据学习目标、当前水平和可用资源生成定制化学习计划。支持技能评估、路径规划、资源推荐、里程碑跟踪等功能。",
"keywords": ["learning","education","path","planning","skills"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/learning-path-navigator",
"language": ["en","zh"],
"tags": ["learning","education","path","planning","skills"],
"createdAt": "2026-04-06",
"updatedAt": "2026-04-06"
}
情绪天气站 - 你的情绪追踪与分析助手。 帮助用户记录情绪、分析模式、推荐调节策略、预警压力。
---
name: emotion-weather-station
slug: emotion-weather-station
version: 0.1.0
description: |
情绪天气站 - 你的情绪追踪与分析助手。
帮助用户记录情绪、分析模式、推荐调节策略、预警压力。
---
# Emotion Weather Station / 情绪天气站
你是**情绪天气站**,一个专注于情绪追踪与分析的智能助手。
## 产品定位
Emotion Weather Station(情绪天气站)帮助用户理解和管理情绪波动。通过情绪日记、模式识别、调节策略推荐和压力预警,提供个性化的情绪健康支持。
核心功能:
- **情绪记录**:快速记录当下的情绪状态
- **模式分析**:识别情绪变化的周期性和模式
- **调节推荐**:根据当前状态推荐个性化调节方法
- **压力预警**:监测压力水平,提前预警
## 使用场景
用户可能会说:
- "记录情绪:焦虑,强度7,触发因素是明天有重要会议"
- "分析我的情绪模式"
- "查看本周情绪报告"
- "推荐情绪调节方法,当前情绪焦虑、紧张,可用时间15分钟"
- "检查我的压力水平"
- "什么让我最常感到压力"
## 输入格式
### 格式1:情绪记录
记录情绪:[情绪关键词]
强度:[1-10]
触发因素:[事件描述]
### 格式2:情绪分析
分析我的情绪模式
查看本周情绪报告
识别情绪触发因素:[可选问题]
### 格式3:调节策略
推荐情绪调节方法
当前情绪:[情绪状态]
可用时间:[分钟数]
### 格式4:压力预警
检查我的压力水平
设置压力预警:[条件]
查看预警历史:[时间范围]
## 输入 schema
```typescript
interface EmotionRequest {
action: "record" | "analyze" | "regulate" | "warning";
emotion?: string;
intensity?: number;
triggers?: string;
period?: "daily" | "weekly" | "monthly";
availableTime?: number;
preferredMethods?: string[];
userId?: string;
}
```
## 输出 schema
```typescript
interface EmotionResponse {
success: boolean;
recordResult?: {
id: string;
emotion: string;
intensity: number;
timestamp: string;
analysis: string;
triggers: string;
};
analysisReport?: {
id: string;
period: string;
summary: {
emotionalHealthScore: number;
trend: "improving" | "stable" | "declining" | "volatile";
keyInsights: string[];
};
emotionDistribution: Record<string, number>;
patterns: { daily: string; weekly: string; };
stressAssessment: {
currentLevel: number;
riskLevel: "low" | "moderate" | "high" | "critical";
warningSigns: string[];
};
triggerAnalysis: {
topTriggers: { trigger: string; frequency: number; impact: number }[];
avoidable: string[];
manageable: string[];
};
};
regulationStrategy?: {
strategyId: string;
name: string;
description: string;
theory: string;
steps: { step: number; action: string; duration: number; tips: string[] }[];
expectedEffects: string[];
timeRequired: number;
}[];
stressWarning?: {
currentLevel: number;
riskLevel: "green" | "yellow" | "orange" | "red";
indicators: string[];
recommendations: string[];
};
error?: string;
}
```
## 情绪分类
支持以下情绪类别:
- 喜悦 (joy)、悲伤 (sadness)、愤怒 (anger)、恐惧 (fear)
- 厌恶 (disgust)、惊讶 (surprise)、信任 (trust)
- 期待 (anticipation)、爱 (love)、中性 (neutral)
## 调节策略分类
基于认知行为疗法和正念技术:
- **认知策略**:认知重构、正念认知
- **行为策略**:行为激活、放松训练
- **正念策略**:呼吸练习、身体扫描
- **生理策略**:渐进式肌肉放松、瑜伽
## 触发词
- 情绪天气站
- 情绪追踪
- 情绪分析
- 压力预警
- 情绪调节
FILE:clawhub.json
{
"name": "emotion-weather-station",
"version": "0.1.0",
"description": "情绪天气站 - 情绪追踪与分析助手,帮助用户记录情绪、分析模式、推荐调节策略、预警压力",
"keywords": ["emotion", "weather-station", "mood-tracking", "stress-management", "emotional-health", "mindfulness"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/emotion-weather-station"
}
FILE:handler.py
"""
Emotion Weather Station - Handler Module
情绪天气站主处理器
"""
import json
import random
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
# ==================== 类型定义 ====================
class EmotionCategory:
"""情绪类别"""
JOY = "joy"
SADNESS = "sadness"
ANGER = "anger"
FEAR = "fear"
DISGUST = "disgust"
SURPRISE = "surprise"
TRUST = "trust"
ANTICIPATION = "anticipation"
LOVE = "love"
NEUTRAL = "neutral"
@staticmethod
def all() -> List[str]:
return [
EmotionCategory.JOY, EmotionCategory.SADNESS,
EmotionCategory.ANGER, EmotionCategory.FEAR,
EmotionCategory.DISGUST, EmotionCategory.SURPRISE,
EmotionCategory.TRUST, EmotionCategory.ANTICIPATION,
EmotionCategory.LOVE, EmotionCategory.NEUTRAL
]
@staticmethod
def get_chinese(emotion: str) -> str:
mapping = {
"joy": "喜悦",
"sadness": "悲伤",
"anger": "愤怒",
"fear": "恐惧",
"disgust": "厌恶",
"surprise": "惊讶",
"trust": "信任",
"anticipation": "期待",
"love": "爱",
"neutral": "中性",
"焦虑": "fear",
"高兴": "joy",
"快乐": "joy",
"悲伤": "sadness",
"愤怒": "anger",
"恐惧": "fear",
"担忧": "fear",
"平静": "neutral",
"中性": "neutral",
}
return mapping.get(emotion, emotion)
class RegulationStrategy:
"""情绪调节策略库"""
STRATEGIES = [
{
"strategyId": "strategy_001",
"name": "深呼吸放松法",
"description": "通过深呼吸激活副交感神经系统,帮助平静情绪",
"theory": "生理调节 - 激活放松反应",
"steps": [
{"step": 1, "action": "找一个舒适的姿势坐下或躺下", "duration": 1, "tips": ["背挺直", "放松肩膀"]},
{"step": 2, "action": "用鼻子缓慢吸气4秒", "duration": 4, "tips": ["让空气充满腹部", "感受腹部膨胀"]},
{"step": 3, "action": "屏住呼吸4秒", "duration": 4, "tips": ["保持身体放松"]},
{"step": 4, "action": "用嘴巴缓慢呼气6秒", "duration": 6, "tips": ["完全呼出空气", "感受腹部收缩"]},
{"step": 5, "action": "重复步骤2-4,共5次", "duration": 20, "tips": ["如果头晕,减少吸气时间"]}
],
"expectedEffects": ["降低心率", "减轻焦虑感", "改善注意力"],
"timeRequired": 10,
"applicability": ["anxiety", "anger", "fear"]
},
{
"strategyId": "strategy_002",
"name": "5-4-3-2-1 grounding technique",
"description": "通过感知 grounding 回归当下,减少焦虑和恐惧",
"theory": "正念技术 - 回归当下",
"steps": [
{"step": 1, "action": "说出5样你能看到的东西", "duration": 2, "tips": ["尽可能详细描述"]},
{"step": 2, "action": "说出4样你能触摸的东西", "duration": 2, "tips": ["感受质地和温度"]},
{"step": 3, "action": "说出3样你能听到的声音", "duration": 2, "tips": ["注意平时忽略的声音"]},
{"step": 4, "action": "说出2样你能闻到的气味", "duration": 2, "tips": ["如果没有明显气味,说出你喜欢的味道"]},
{"step": 5, "action": "说出1样你能尝到的味道", "duration": 1, "tips": ["注意口腔中的味道"]}
],
"expectedEffects": ["快速缓解焦虑", "回归当下感", "减少恐慌"],
"timeRequired": 5,
"applicability": ["anxiety", "fear", "panic"]
},
{
"strategyId": "strategy_003",
"name": "认知重构 - 思维记录",
"description": "识别和挑战负面自动思维,建立更平衡的认知",
"theory": "认知行为疗法 - 认知重构",
"steps": [
{"step": 1, "action": "写下让你困扰的想法", "duration": 3, "tips": ["原样记录,不要修改"]},
{"step": 2, "action": "标注这个想法引发的情绪", "duration": 2, "tips": ["用1-10评估情绪强度"]},
{"step": 3, "action": "问自己:这个想法的证据是什么?", "duration": 3, "tips": ["寻找支持和反对的证据"]},
{"step": 4, "action": "换一个角度看待这件事", "duration": 3, "tips": ["如果是朋友遇到这事,你会怎么说?"]},
{"step": 5, "action": "写下新的、更平衡的想法", "duration": 2, "tips": ["比较新旧想法的差异"]}
],
"expectedEffects": ["减少思维反刍", "改善情绪", "增强认知灵活性"],
"timeRequired": 15,
"applicability": ["anxiety", "sadness", "anger"]
},
{
"strategyId": "strategy_004",
"name": "渐进式肌肉放松",
"description": "通过交替紧张和放松肌肉群,释放身体紧张",
"theory": "生理调节 - 身体放松",
"steps": [
{"step": 1, "action": "从脚开始,紧绷脚部肌肉5秒", "duration": 5, "tips": ["用力但不要过度"]},
{"step": 2, "action": "放松脚部,感受舒适感", "duration": 10, "tips": ["注意紧张和放松的对比"]},
{"step": 3, "action": "依次向上:小腿、大腿、臀部", "duration": 15, "tips": ["每部位重复紧张-放松"]},
{"step": 4, "action": "继续:腹部、胸部、背部", "duration": 15, "tips": ["保持呼吸平稳"]},
{"step": 5, "action": "最后:双手、肩膀、脸部", "duration": 15, "tips": ["脸部要特别放松"]}
],
"expectedEffects": ["深度身体放松", "改善睡眠", "减轻头痛"],
"timeRequired": 20,
"applicability": ["anxiety", "anger", "stress"]
},
{
"strategyId": "strategy_005",
"name": "行为激活 - 愉悦活动",
"description": "通过参与积极活动改善情绪,打破抑郁循环",
"theory": "行为疗法 - 行为激活",
"steps": [
{"step": 1, "action": "列出5项让你感到愉悦或有成就感的活动", "duration": 3, "tips": ["可以是简单的如散步、听音乐"]},
{"step": 2, "action": "选择一项最容易开始的活动", "duration": 1, "tips": ["不要追求完美,从小事开始"]},
{"step": 3, "action": "设定具体时间和地点", "duration": 1, "tips": ["明确何时何地开始"]},
{"step": 4, "action": "开始活动,全心投入", "duration": 15, "tips": ["过程中注意自己的感受"]},
{"step": 5, "action": "记录活动后的情绪变化", "duration": 2, "tips": ["这对未来规划很重要"]}
],
"expectedEffects": ["改善情绪", "增加活动量", "打破回避循环"],
"timeRequired": 15,
"applicability": ["sadness", "anxiety", "neutral"]
}
]
@staticmethod
def recommend(emotions: List[str], intensity: int, available_time: int) -> List[Dict]:
"""根据情绪类型和可用时间推荐策略"""
recommendations = []
for strategy in RegulationStrategy.STRATEGIES:
# 检查适用性
applicable = any(e in strategy["applicability"] for e in emotions)
time_fit = strategy["timeRequired"] <= available_time
if applicable and time_fit:
recommendations.append(strategy)
# 如果没有完全匹配的,返回所有适合时间的策略
if not recommendations:
recommendations = [s for s in RegulationStrategy.STRATEGIES
if s["timeRequired"] <= available_time]
# 限制返回数量
return recommendations[:3]
# ==================== 核心处理器 ====================
class EmotionWeatherStation:
"""情绪天气站主处理器"""
def __init__(self):
self.name = "Emotion Weather Station"
self.version = "0.1.0"
def parse_input(self, user_input: str) -> Dict[str, Any]:
"""解析用户输入,生成结构化的 EmotionRequest"""
user_input_lower = user_input.lower()
request = {"action": None, "raw_input": user_input}
# 判断操作类型
if any(kw in user_input_lower for kw in ["记录", "record"]):
request["action"] = "record"
request.update(self._parse_record(user_input))
elif any(kw in user_input_lower for kw in ["分析", "analyze", "报告", "report", "模式"]):
request["action"] = "analyze"
request.update(self._parse_analyze(user_input))
elif any(kw in user_input_lower for kw in ["调节", "regulate", "推荐", "recommend", "方法"]):
request["action"] = "regulate"
request.update(self._parse_regulate(user_input))
elif any(kw in user_input_lower for kw in ["压力", "warning", "预警", "stress", "检查"]):
request["action"] = "warning"
request.update(self._parse_warning(user_input))
else:
request["action"] = "analyze" # 默认行为
return request
def _parse_record(self, text: str) -> Dict:
"""解析情绪记录输入"""
result = {"emotion": None, "intensity": 5, "triggers": ""}
# 提取情绪
emotion_keywords = ["焦虑", "高兴", "快乐", "悲伤", "愤怒", "恐惧", "担忧",
"平静", "joy", "anxiety", "sadness", "anger", "fear", "happy"]
for kw in emotion_keywords:
if kw in text.lower():
result["emotion"] = EmotionCategory.get_chinese(kw)
break
if not result["emotion"]:
result["emotion"] = "neutral"
# 提取强度
import re
intensity_match = re.search(r'强度[::]?\s*(\d+)', text)
if intensity_match:
result["intensity"] = min(10, max(1, int(intensity_match.group(1))))
# 提取触发因素
trigger_match = re.search(r'触发[因素]?[::]?\s*([^\\n]+)', text)
if trigger_match:
result["triggers"] = trigger_match.group(1).strip()
return result
def _parse_analyze(self, text: str) -> Dict:
"""解析分析请求"""
result = {"period": "weekly"}
if "daily" in text.lower() or "今天" in text or "日" in text:
result["period"] = "daily"
elif "monthly" in text.lower() or "本月" in text or "月" in text:
result["period"] = "monthly"
return result
def _parse_regulate(self, text: str) -> Dict:
"""解析调节策略请求"""
result = {"emotions": [], "availableTime": 15}
# 提取情绪
emotion_keywords = ["焦虑", "紧张", "高兴", "快乐", "悲伤", "愤怒", "恐惧",
"平静", "担忧", "anxiety", "stress", "happy", "sad", "angry"]
for kw in emotion_keywords:
if kw in text.lower():
result["emotions"].append(EmotionCategory.get_chinese(kw))
if not result["emotions"]:
result["emotions"] = ["anxiety"]
# 提取时间
import re
time_match = re.search(r'(\d+)\s*[分钟分]', text)
if time_match:
result["availableTime"] = int(time_match.group(1))
return result
def _parse_warning(self, text: str) -> Dict:
"""解析压力预警请求"""
return {}
def process(self, request: Dict[str, Any]) -> Dict[str, Any]:
"""处理请求并生成响应"""
action = request.get("action")
if action == "record":
return self._process_record(request)
elif action == "analyze":
return self._process_analyze(request)
elif action == "regulate":
return self._process_regulate(request)
elif action == "warning":
return self._process_warning(request)
else:
return self._process_analyze(request)
def _process_record(self, request: Dict) -> Dict:
"""处理情绪记录"""
emotion = request.get("emotion", "neutral")
intensity = request.get("intensity", 5)
triggers = request.get("triggers", "")
# 生成记录ID
record_id = f"rec_{datetime.now().strftime('%Y%m%d%H%M%S')}"
# 生成简单分析
analysis = self._generate_record_analysis(emotion, intensity)
return {
"success": True,
"recordResult": {
"id": record_id,
"emotion": emotion,
"intensity": intensity,
"timestamp": datetime.now().isoformat(),
"analysis": analysis,
"triggers": triggers
}
}
def _generate_record_analysis(self, emotion: str, intensity: int) -> str:
"""生成情绪记录分析"""
emotion_ch = EmotionCategory.get_chinese(emotion)
if intensity <= 3:
intensity_desc = "较轻微"
elif intensity <= 6:
intensity_desc = "中等"
else:
intensity_desc = "强烈"
analyses = {
"joy": f"你正在体验{intensity_desc}的喜悦情绪,这是非常积极的体验。",
"sadness": f"你正在经历{intensity_desc}的悲伤。请记得,悲伤是人类正常的情绪反应。",
"anger": f"你感到{intensity_desc}的愤怒。尝试用深呼吸来平复情绪。",
"fear": f"你正在体验{intensity_desc}的恐惧或焦虑。尝试 grounding 技术帮助回归当下。",
"neutral": f"你的情绪较为平静,这是进行自我反思的好时机。",
}
return analyses.get(emotion, f"你正在体验{emotion_ch}情绪,强度{intensity}分。")
def _process_analyze(self, request: Dict) -> Dict:
"""处理情绪分析请求"""
period = request.get("period", "weekly")
return {
"success": True,
"analysisReport": {
"id": f"report_{datetime.now().strftime('%Y%m%d%H%M%S')}",
"period": period,
"summary": {
"emotionalHealthScore": random.randint(60, 85),
"trend": random.choice(["improving", "stable", "declining", "volatile"]),
"keyInsights": [
"本周积极情绪比例有所提升",
f"{random.choice(['工作日下午', '晚间', '周末'])}是情绪低谷期",
f"{random.choice(['户外活动', '社交互动', '充分睡眠'])}后情绪明显改善"
]
},
"emotionDistribution": {
"joy": random.randint(10, 30),
"calm": random.randint(15, 35),
"anxiety": random.randint(5, 20),
"frustration": random.randint(5, 15),
"neutral": random.randint(10, 25)
},
"patterns": {
"daily": random.choice([
"早晨情绪最佳,下午逐渐下降,晚上恢复",
"一天中情绪相对平稳,偶有波动",
"晚间情绪较为敏感,容易受外界影响"
]),
"weekly": random.choice([
"周一压力最高,周末情绪最好",
"周中情绪波动较大,头尾相对稳定",
"周末积极情绪明显增加"
])
},
"stressAssessment": {
"currentLevel": random.randint(3, 7),
"riskLevel": random.choice(["low", "moderate", "high", "critical"]),
"warningSigns": [
random.choice(["睡眠质量有所下降", "注意力偶尔难以集中", "易怒频率略有增加", "暂无明显预警信号"])
],
"resilienceScore": random.randint(55, 80)
},
"triggerAnalysis": {
"topTriggers": [
{"trigger": "工作压力", "frequency": random.randint(5, 10), "impact": random.randint(6, 9)},
{"trigger": "人际关系", "frequency": random.randint(3, 8), "impact": random.randint(5, 8)},
{"trigger": "睡眠质量", "frequency": random.randint(2, 6), "impact": random.randint(4, 7)},
{"trigger": "健康状况", "frequency": random.randint(1, 4), "impact": random.randint(3, 6)}
],
"avoidable": ["不必要的社交比较", "深夜刷手机"],
"manageable": ["工作压力", "睡眠习惯", "运动频率"]
}
}
}
def _process_regulate(self, request: Dict) -> Dict:
"""处理调节策略请求"""
emotions = request.get("emotions", ["anxiety"])
available_time = request.get("availableTime", 15)
strategies = RegulationStrategy.recommend(emotions, 5, available_time)
return {
"success": True,
"regulationStrategy": strategies
}
def _process_warning(self, request: Dict) -> Dict:
"""处理压力预警请求"""
current_level = random.randint(30, 70)
if current_level < 40:
risk_level = "green"
elif current_level < 55:
risk_level = "yellow"
elif current_level < 75:
risk_level = "orange"
else:
risk_level = "red"
return {
"success": True,
"stressWarning": {
"currentLevel": current_level,
"riskLevel": risk_level,
"indicators": [
"最近情绪波动较为明显",
f"压力水平处于{risk_level}区间"
],
"recommendations": [
"建议增加放松练习",
"保持规律作息",
"适当增加运动"
]
}
}
# ==================== Handler 入口 ====================
def handle(user_input: str, context: Optional[Dict] = None) -> str:
"""
处理用户输入的主函数
这是 OpenClaw Skill 的标准入口点
"""
try:
station = EmotionWeatherStation()
# 解析输入
request = station.parse_input(user_input)
# 处理请求
response = station.process(request)
# 格式化为 JSON 字符串
return json.dumps(response, ensure_ascii=False, indent=2)
except Exception as e:
error_response = {
"success": False,
"error": f"处理失败: {str(e)}"
}
return json.dumps(error_response, ensure_ascii=False, indent=2)
# ==================== 测试代码 ====================
if __name__ == "__main__":
# 简单测试
test_cases = [
"记录情绪:焦虑,强度7,触发因素是明天有重要会议",
"分析我的情绪模式",
"推荐情绪调节方法,当前情绪焦虑、紧张,可用时间15分钟",
"检查我的压力水平"
]
print("=" * 60)
print("Emotion Weather Station - 自测")
print("=" * 60)
station = EmotionWeatherStation()
for i, test in enumerate(test_cases, 1):
print(f"\n【测试 {i}】输入: {test}")
print("-" * 40)
request = station.parse_input(test)
print(f"解析结果: {json.dumps(request, ensure_ascii=False)}")
response = station.process(request)
print(f"处理结果: {response}")
print()
FILE:package.json
{
"name": "emotion-weather-station",
"version": "0.1.0",
"description": "Emotion Weather Station - 情绪追踪与分析助手",
"type": "module",
"main": "index.js",
"scripts": {
"test": "python3 handler.py"
}
}
FILE:scripts/test.py
#!/usr/bin/env python3
"""
Emotion Weather Station - Test Script
"""
import sys
import json
sys.path.insert(0, '.')
from handler import handle
def test_record():
print("=== Test: record ===")
result = handle("记录情绪:焦虑,强度7,触发因素是明天有重要会议")
data = json.loads(result)
assert data.get("success") == True
assert "recordResult" in data
print(f" PASS: recorded {data['recordResult']['emotion']} intensity {data['recordResult']['intensity']}")
def test_analysis():
print("=== Test: analysis ===")
result = handle("分析我这一周的情绪")
data = json.loads(result)
assert data.get("success") == True
print(f" PASS: analysis returned")
def test_insight():
print("=== Test: insight ===")
result = handle("给我一些情绪调节建议")
data = json.loads(result)
assert data.get("success") == True
print(f" PASS: insight returned")
def test_pattern():
print("=== Test: pattern ===")
result = handle("发现我的情绪模式")
data = json.loads(result)
assert data.get("success") == True
print(f" PASS: pattern returned")
def test_weekly_summary():
print("=== Test: weekly_summary ===")
result = handle("生成情绪周报")
data = json.loads(result)
assert data.get("success") == True
print(f" PASS: weekly_summary returned")
def run_all():
print("============================================================")
print("Emotion Weather Station - Test Script")
print("============================================================")
tests = [test_record, test_analysis, test_insight, test_pattern, test_weekly_summary]
passed = 0
for t in tests:
try:
t()
passed += 1
except Exception as e:
print(f" FAIL: {e}")
print(f"============================================================")
print(f"Results: {passed}/{len(tests)} passed")
return passed == len(tests)
if __name__ == "__main__":
success = run_all()
sys.exit(0 if success else 1)
FILE:skill.json
{
"name": "emotion-weather-station",
"version": "0.1.0",
"description": "情绪天气站 - 你的情绪追踪与分析助手. 帮助用户记录情绪、分析模式、推荐调节策略、预警压力。",
"keywords": ["emotion","mental-health","mood","tracking","wellness"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/emotion-weather-station",
"language": ["en","zh"],
"tags": ["emotion","mental-health","mood","tracking","wellness"],
"createdAt": "2026-04-06",
"updatedAt": "2026-04-06"
}
Creative Inspiration Hub / 创意灵感孵化器. 通过跨领域组合、灵感触发、创意评估和思维导图生成,帮助创意工作者突破瓶颈。
---
name: creative-inspiration-hub
slug: creative-inspiration-hub
version: 0.1.0
description: |
Creative Inspiration Hub / 创意灵感孵化器.
通过跨领域组合、灵感触发、创意评估和思维导图生成,帮助创意工作者突破瓶颈。
---
# Creative Inspiration Hub / 创意灵感孵化器
你是**创意灵感孵化器**。
你的任务是帮助创意工作者突破创作瓶颈,通过跨领域组合、灵感触发、创意评估和思维导图生成,发现新颖的创意方向。
## 产品定位
Creative Inspiration Hub 是一个创意激发系统,核心价值:
- **跨领域组合**:将不同领域的元素随机组合,产生新颖联想
- **灵感触发**:生成激发创意的关键词和概念
- **创意评估**:评估创意的原创性、可行性和价值
- **思维导图**:将创意概念可视化,展示关联和发展路径
## 使用场景
用户可能会说:
- "为智能家居生成创意想法"
- "我在产品设计遇到瓶颈,所有想法都太常规"
- "组合生物学和建筑设计产生新创意"
- "评估这个创意:基于区块链的二手书交易平台"
## 触发词
- `创意灵感孵化器`
- `创意激发`
- `突破创作瓶颈`
- `跨领域灵感`
- `创意组合生成`
## 输入 schema
```typescript
interface InspirationRequest {
type: "idea-generation" | "cross-domain" | "inspiration-trigger" | "evaluation" | "mindmap";
theme?: string;
domains?: string[];
constraints?: string[];
blocker?: string;
existingIdeas?: string[];
preferences?: { style?: "radical" | "moderate" | "conservative"; riskTolerance?: "low" | "medium" | "high"; };
domainA?: string;
domainB?: string;
applicationScenario?: string;
ideaToEvaluate?: string;
evaluationDimensions?: ("novelty" | "feasibility" | "value" | "originality")[];
coreConcept?: string;
relatedThoughts?: string[];
}
```
## 输出 schema
```typescript
interface InspirationReport {
success: boolean;
sessionId: string;
ideas?: CreativeIdea[];
combinations?: CrossDomainResult[];
triggers?: InspirationTriggerWord[];
evaluation?: IdeaEvaluation;
mindmap?: MindMapResult;
metadata: { requestType: string; processingTime: number; model: string; };
}
```
## 指令格式示例
### 1. 主题创意生成
```
为 智能家居 生成创意想法
领域:technology, design, environment
```
### 2. 跨领域组合
```
组合 biology 和 architecture 产生新创意
应用场景:可持续城市设计
```
### 3. 灵感触发
```
我在 产品设计 遇到瓶颈:思维定式
需要新的创意方向
```
### 4. 创意评估
```
评估这个创意:基于区块链的二手书交易平台
评估维度:新颖性、可行性、市场价值
```
### 5. 思维导图
```
生成思维导图
核心概念:未来办公空间设计
相关想法:灵活工位、健康环境、智能协作
```
## 支持的领域
- technology(科技)
- art(艺术)
- science(科学)
- business(商业)
- design(设计)
- education(教育)
- health(健康)
- environment(环境)
- entertainment(娱乐)
- social(社会)
- cultural(文化)
## 当前状态
v0.1.0 MVP 骨架版本,所有功能返回 mock 数据。
## 目录结构
```
creative-inspiration-hub/
├── SKILL.md # 技能定义
├── clawhub.json # 技能元数据
├── package.json # 依赖配置
├── handler.py # 主逻辑入口
├── engine/ # 创意引擎(预留)
├── data/ # 数据文件(预留)
└── scripts/ # 工具脚本
└── test.py # 自测脚本
```
## 使用示例
```python
from handler import handle_request
# 创意生成
result = handle_request({
"type": "idea-generation",
"theme": "智能家居",
"domains": ["technology", "design"]
})
# 跨领域组合
result = handle_request({
"type": "cross-domain",
"domainA": "biology",
"domainB": "architecture",
"applicationScenario": "可持续城市设计"
})
# 灵感触发
result = handle_request({
"type": "inspiration-trigger",
"theme": "产品创新",
"blocker": "思维定式"
})
# 创意评估
result = handle_request({
"type": "evaluation",
"ideaToEvaluate": "基于区块链的二手书交易平台"
})
# 思维导图
result = handle_request({
"type": "mindmap",
"coreConcept": "未来办公空间设计",
"relatedThoughts": ["灵活工位", "健康环境", "智能协作"]
})
```
FILE:clawhub.json
{
"name": "creative-inspiration-hub",
"version": "0.1.0",
"description": "创意灵感孵化器 - 通过跨领域组合、灵感触发、创意评估和思维导图生成,帮助创意工作者突破瓶颈",
"keywords": ["creative", "inspiration", "ideation", "mindmap", "innovation", "cross-domain"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/creative-inspiration-hub"
}
FILE:handler.py
#!/usr/bin/env python3
import json, uuid, time
from typing import Dict, Any
class InspirationHub:
def __init__(self):
self.session_id = str(uuid.uuid4())[:8]
self.domains = self._load_domains()
def _load_domains(self):
return {
"technology": {"concepts": ["人工智能", "区块链", "物联网"], "principles": ["自动化", "智能化"], "trends": ["AIGC", "元宇宙"]},
"art": {"concepts": ["绘画", "雕塑", "装置艺术"], "principles": ["表达", "审美"], "trends": ["NFT艺术", "生成艺术"]},
"science": {"concepts": ["物理学", "化学", "生物学"], "principles": ["观察", "实验"], "trends": ["脑科学", "基因编辑"]},
"business": {"concepts": ["市场营销", "运营管理", "战略规划"], "principles": ["价值创造", "效率优化"], "trends": ["数字化转型", "平台经济"]},
"design": {"concepts": ["工业设计", "平面设计", "用户体验设计"], "principles": ["功能美学", "以人为本"], "trends": ["情感化设计", "无障碍设计"]},
"education": {"concepts": ["课程设计", "教学方法", "学习评估"], "principles": ["因材施教", "启发式教学"], "trends": ["微学习", "游戏化学习"]},
"health": {"concepts": ["预防医学", "康复治疗", "心理健康"], "principles": ["预防为主", "整体观"], "trends": ["数字疗法", "精准医疗"]},
"environment": {"concepts": ["气候变化", "生态系统", "资源循环"], "principles": ["生态优先", "循环经济"], "trends": ["碳中和", "零废弃"]},
"entertainment": {"concepts": ["游戏", "影视", "音乐"], "principles": ["沉浸体验", "情感代入"], "trends": ["云游戏", "互动影视"]},
"social": {"concepts": ["社区建设", "公共服务", "社会创新"], "principles": ["包容性", "参与性"], "trends": ["共享经济", "社会企业"]},
"cultural": {"concepts": ["文化遗产", "博物馆", "民俗传统"], "principles": ["保护传承", "创新发展"], "trends": ["数字文博", "非遗新生"]}
}
def process(self, request):
start = time.time()
rtype = request.get("type", "idea-generation")
if rtype == "idea-generation": result = self._gen_ideas(request)
elif rtype == "cross-domain": result = self._cross_domain(request)
elif rtype == "inspiration-trigger": result = self._gen_triggers(request)
elif rtype == "evaluation": result = self._eval_idea(request)
elif rtype == "mindmap": result = self._gen_mindmap(request)
else: result = {"error": f"Unknown: {rtype}"}
return {"success": True, "sessionId": f"session_{self.session_id}", **result, "metadata": {"requestType": rtype, "processingTime": int((time.time()-start)*1000), "model": "cih-v0.1"}}
def _gen_ideas(self, req):
theme = req.get("theme", "通用创意")
domains = req.get("domains", ["technology"])
ideas = []
for i in range(3):
d = domains[i % len(domains)]
dd = self.domains.get(d, self.domains["technology"])
ideas.append({"id": f"idea_{uuid.uuid4().hex[:6]}", "title": f"{theme}+{dd['concepts'][i]}", "description": f"结合{theme}和{d}", "origin": {"type": "combination", "sourceDomains": [d], "inspirationTriggers": dd["trends"]}, "evaluation": {"novelty": {"score": 7+(i%3), "rationale": "跨领域组合"}, "feasibility": {"score": 6+(i%4), "requirements": ["资源"], "challenges": ["难度"]}, "value": {"score": 7+(i%3), "beneficiaries": ["用户"]}, "originality": {"score": 6+(i%4)}, "overall": 7+(i%2)}, "potential": {"scalability": 7, "adaptability": 6, "sustainability": 7, "evolutionPaths": ["扩展", "升级"]}})
return {"ideas": ideas}
def _cross_domain(self, req):
da, db = req.get("domainA", "technology"), req.get("domainB", "biology")
scenario = req.get("applicationScenario", "创新应用")
dda, ddb = self.domains.get(da, self.domains["technology"]), self.domains.get(db, self.domains["science"])
pairs = [{"conceptA": dda["concepts"][i], "conceptB": ddb["concepts"][i], "relationship": f"{da}原理在{db}应用"} for i in range(min(3, len(dda["concepts"])))]
return {"combinations": [{"id": f"combo_{uuid.uuid4().hex[:6]}", "domainA": da, "domainB": db, "combinationType": "fusion", "strength": 8, "synergy": 7, "inspirations": {"conceptPairs": pairs, "metaphorSuggestions": [f"像{dda['concepts'][0]}一样{ddb['principles'][0]}"], "applicationAreas": [scenario]}, "implementation": {"steps": ["调研", "寻找交叉点", "构建原型"], "tools": ["思维导图", "原型软件"], "skills": ["跨学科", "系统思考"]}}]}
def _gen_triggers(self, req):
words = [{"word": "转化", "category": "action", "semantics": {"connotations": ["转变"], "associations": ["蜕变"], "metaphors": ["蝴蝶破茧"]}}, {"word": "连接", "category": "action", "semantics": {"connotations": ["桥梁"], "associations": ["网络"], "metaphors": ["蛛网"]}}, {"word": "打破", "category": "action", "semantics": {"connotations": ["突破"], "associations": ["常规"], "metaphors": ["破冰"]}}, {"word": "融合", "category": "action", "semantics": {"connotations": ["混合"], "associations": ["化学反应"], "metaphors": ["调色板"]}}, {"word": "逆向", "category": "action", "semantics": {"connotations": ["反思"], "associations": ["反常识"], "metaphors": ["镜子"]}}]
return {"triggers": [{"word": w["word"], "category": w["category"], "semantics": w["semantics"], "creativePotential": {"divergentThinking": 7+(i%3), "conceptualBreadth": 6+(i%4), "emotionalResonance": 7+(i%2)}} for i, w in enumerate(words[:5])]}
def _eval_idea(self, req):
idea = req.get("ideaToEvaluate", "创新想法")
return {"evaluation": {"idea": idea, "scores": {"novelty": 7, "feasibility": 6, "value": 8, "originality": 7, "overall": 7}, "analysis": {"strengths": ["解决痛点", "有创新性"], "weaknesses": ["技术难度", "资源投入"], "improvementSuggestions": ["MVP验证", "差异化"], "risks": ["市场风险", "技术风险"]}, "recommendations": ["用户调研", "分阶段推出"]}}
def _gen_mindmap(self, req):
core = req.get("coreConcept", "创新")
related = req.get("relatedThoughts", ["想法1", "想法2"])
nodes = [{"id": "node_root", "content": core, "type": "concept", "importance": 10, "children": []}]
for i, t in enumerate(related):
cid = f"node_{i+1}"
nodes.append({"id": cid, "content": t, "type": "concept", "importance": 7, "children": []})
nodes[0]["children"].append(cid)
return {"mindmap": {"title": f"{core}思维导图", "structure": {"layout": "radial", "depth": 2, "nodeCount": len(nodes)}, "nodes": nodes, "connections": [{"from": "node_root", "to": f"node_{i+1}", "type": "association", "strength": 8} for i in range(len(related))], "developmentSuggestions": ["扩展分支", "添加跨分支连接"]}}
def handle_request(req): return InspirationHub().process(req)
if __name__ == "__main__":
print(json.dumps(handle_request({"type": "idea-generation", "theme": "智能家居", "domains": ["technology", "design"]}), ensure_ascii=False, indent=2))
FILE:package.json
{
"name": "creative-inspiration-hub",
"version": "0.1.0",
"description": "Creative Inspiration Hub - 创意灵感孵化器,帮助创意工作者突破瓶颈",
"type": "module",
"main": "index.js",
"scripts": {
"test": "python3 handler.py"
}
}
FILE:scripts/test.py
#!/usr/bin/env python3
"""Creative Inspiration Hub - Full Test Script"""
import sys
import json
sys.path.insert(0, '.')
from handler import handle_request
def test_all_branches():
print("=== Creative Inspiration Hub Full Test ===\n")
tests = [
("idea-generation", {"type": "idea-generation", "theme": "智能家居", "domains": ["technology", "design"]}),
("cross-domain", {"type": "cross-domain", "domainA": "technology", "domainB": "biology"}),
("inspiration-trigger", {"type": "inspiration-trigger", "keywords": ["创新", "突破"]}),
("evaluation", {"type": "evaluation", "ideaToEvaluate": "基于AI的推荐系统"}),
("mindmap", {"type": "mindmap", "coreConcept": "创新", "relatedThoughts": ["想法1", "想法2"]})
]
results = []
for name, req in tests:
try:
result = handle_request(req)
success = result.get("success", False)
has_data = (
result.get("ideas") or
result.get("combinations") or
result.get("triggers") or
result.get("evaluation") or
result.get("mindmap")
)
status = "PASS" if success and has_data else "FAIL"
print(f"{name}: {status}")
if not has_data:
print(f" Warning: {list(result.keys())}")
results.append((name, status))
except Exception as e:
print(f"{name}: ERROR - {e}")
results.append((name, "ERROR"))
print(f"\n=== Summary: {sum(1 for _, s in results if s == 'PASS')}/{len(results)} passed ===")
return all(s == "PASS" for _, s in results)
if __name__ == "__main__":
success = test_all_branches()
sys.exit(0 if success else 1)
FILE:skill.json
{
"name": "creative-inspiration-hub",
"version": "0.1.0",
"description": "Creative Inspiration Hub / 创意灵感孵化器. 通过跨领域组合、灵感触发、创意评估和思维导图生成,帮助创意工作者突破瓶颈。",
"keywords": ["creativity","inspiration","design","brainstorming"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/creative-inspiration-hub",
"language": ["en","zh"],
"tags": ["creativity","inspiration","design","brainstorming"],
"createdAt": "2026-04-06",
"updatedAt": "2026-04-06"
}
LinkMind 知识连接引擎 Phase 2 - 本地化知识中枢 CLI 工具,支持 storage adapter 抽象层和 OpenAI-compatible embedding provider。
---
name: linkmind
description: LinkMind 知识连接引擎 Phase 2 - 本地化知识中枢 CLI 工具,支持 storage adapter 抽象层和 OpenAI-compatible embedding provider。
---
# LinkMind 知识连接引擎 (Phase 2)
LinkMind 是一个本地化知识连接引擎,将异构内容沉淀为统一知识单元,建立可解释的节点连接网络,并支持带证据的查询回答。
## Phase 2 新增功能
- **Storage Adapter 抽象层**:`JsonStorageAdapter`(默认,保持兼容)+ `SqliteStorageAdapter`(基于 better-sqlite3)
- **Embedding Provider 抽象层**:`MockProvider`(离线测试)+ `OpenAICompatibleProvider`(接入 OpenAI/vLLM/Ollama)
- **向量相似度召回**:余弦相似度 ranker,与关键词召回结果合并去重
- **渐进升级**:Phase 1 JSON 方案完全兼容,无需迁移
## 核心功能
- **摄入 (ingest)**: 读取本地文本文件,切分为语义友好的段落片段,抽取概念节点,建立片段-概念、片段-片段邻接连接
- **查询 (query)**: 支持关键词/概念查询 + 向量相似度召回,返回答案摘要、证据片段列表、相关概念节点、统计概览
- **状态 (status)**: 查看当前工作空间统计信息
- **重置 (reset)**: 清空工作空间
## 架构模块
| 模块 | 职责 | 状态 |
|------|------|------|
| `storage-adapters/` | StorageAdapter 接口 + JSON/SQLite 双实现 | ✅ Phase 2 |
| `embedding-providers/` | EmbeddingProvider 接口 + Mock/OpenAI 双实现 | ✅ Phase 2 |
| `retriever` | 关键词召回 + 向量召回双层检索,余弦相似度 ranker | ✅ Phase 2 |
| `unit-builder` | 文档切分为 fragment,抽取 fragment.conceptNames | ✅ MVP |
| `link-builder` | fragment↔concept、fragment↔fragment 邻接连接 | ✅ MVP |
| `answer-composer` | 组装 answer + evidence + relatedConcepts | ✅ MVP |
| `ingest-normalizer` | 原始文件标准化为 Document | ✅ MVP |
| `guardrails` | 空查询、空结果边界处理 | ✅ MVP |
## 技术栈
- 零外部依赖(pure Node.js built-ins)
- 本地 JSON 文件存储(`data/workspace.json`)
- 可选 SQLite 存储(`better-sqlite3`)
- 数据模型: Document → Fragment → Concept + LinkEdge
## 安装与运行
```bash
cd skills/linkmind
# 构建
npm run build
# 运行 CLI
node dist/index.js --help
```
## 使用方法
### 摄入文档
```bash
node dist/index.js ingest --file examples/sample-note.md --title "我的笔记" --sourceType note
```
### 查询知识
```bash
node dist/index.js query --q "知识连接"
node dist/index.js query --q "knowledge" --limit 5
```
### 查看状态
```bash
node dist/index.js status
```
### 重置工作空间
```bash
node dist/index.js reset
```
## 存储适配器 (Storage Adapter)
### JsonStorageAdapter(默认)
- 数据存储在 `data/workspace.json`
- 完全向后兼容 Phase 1
- 零配置开箱即用
### SqliteStorageAdapter
- 需要安装:`npm install better-sqlite3`
- 数据存储在 `data/db.sqlite`
- 支持事务批量写入,性能更高
```js
import { createStorageAdapter } from './src/storage-adapters/index.js';
const adapter = createStorageAdapter('sqlite');
await adapter.init();
await adapter.saveDocument({ id: 'doc_1', title: 'Test', ... });
```
## Embedding Provider
### MockProvider(默认,离线可用)
```js
import { createEmbeddingProvider } from './src/embedding-providers/index.js';
const provider = createEmbeddingProvider('mock', { dimension: 1536 });
const [vec] = await provider.embed(['hello world']);
```
### OpenAICompatibleProvider
```js
const provider = createEmbeddingProvider('openai', {
baseURL: 'https://api.openai.com/v1', // 或 vLLM/Ollama 地址
apiKey: 'sk-xxx',
model: 'text-embedding-3-small',
dimension: 1536
});
const vectors = await provider.embed(['hello', 'world']);
```
## 检索流程 (Retriever)
查询时使用双层召回:
1. **关键词召回**(keyword):基于 `normalizeConcept` + concept 名称匹配
2. **向量召回**(vector):余弦相似度(可选,需配置 embedding provider)
3. **合并去重**:取最高分,结果标记 `source: 'keyword' | 'vector' | 'hybrid'`
```js
import { retrieve } from './src/retriever.js';
const results = await retrieve({
fragments,
query: 'knowledge graph',
embeddingProvider: mockProvider, // 传 null 则仅关键词召回
limit: 10
});
// results: [{ fragmentId, documentId, documentTitle, score, text, source }]
```
## 数据模型
### Document
```json
{
"id": "doc_xxxxx",
"type": "document",
"title": "我的笔记",
"sourceType": "note",
"sourceUri": "/path/to/file.md",
"importedAt": "2026-04-04T...",
"status": "active"
}
```
### Fragment
```json
{
"id": "frag_xxxxx",
"type": "fragment",
"documentId": "doc_xxxxx",
"index": 0,
"text": "LinkMind 是...",
"summary": "LinkMind 是...",
"conceptNames": ["linkmind", "知识连接", "知识中枢"]
}
```
### Concept
```json
{
"id": "concept_xxxxx",
"type": "concept",
"name": "LinkMind",
"normalizedName": "linkmind",
"salience": 0.67
}
```
### LinkEdge
```json
{
"id": "link_xxxxx",
"type": "mentions",
"fromId": "frag_xxxxx",
"fromType": "fragment",
"toId": "concept_xxxxx",
"toType": "concept",
"score": 2
}
```
## 自测方法
```bash
cd skills/linkmind
node tests/smoke-test.js
```
## 目录结构
```
skills/linkmind/
├── SKILL.md
├── README.md
├── package.json
├── src/
│ ├── index.js # CLI 入口
│ ├── retriever.js # 双层检索
│ ├── storage-adapters/
│ │ ├── StorageAdapter.js # 接口契约
│ │ ├── JsonStorageAdapter.js # JSON 文件实现
│ │ ├── SqliteStorageAdapter.js # SQLite 实现
│ │ └── index.js # Factory
│ ├── embedding-providers/
│ │ ├── EmbeddingProvider.js # 接口契约
│ │ ├── MockProvider.js # 随机向量(测试用)
│ │ ├── OpenAICompatibleProvider.js # OpenAI 兼容 API
│ │ └── index.js # Factory
│ └── utils/
│ └── nlp.js # normalizeConcept 工具
├── dist/
│ └── index.js
├── data/
│ └── workspace.json
├── tests/
│ └── smoke-test.js # Phase 2 覆盖 38 项检查
└── examples/
└── sample-note.md
```
## Phase 2 交付清单
- [x] StorageAdapter 接口契约
- [x] JsonStorageAdapter(向后兼容)
- [x] SqliteStorageAdapter(基于 better-sqlite3)
- [x] EmbeddingProvider 接口契约
- [x] MockProvider(确定性随机、缓存、L2归一化)
- [x] OpenAICompatibleProvider(支持自定义 baseURL/apiKey/model)
- [x] retriever.js:关键词+向量双层召回 + 余弦相似度 ranker + merge 去重
- [x] smoke-test.js Phase 2 全覆盖(38项检查全部通过)
- [x] SKILL.md 更新
## 待后续实现
- ⏳ index.js 接入 adapter 层(DI 注入,支持 --storage=json|sqlite)
- ⏳ 向量批量预计算 + 离线向量存储
- ⏳ 图数据库存储(Neo4j)
- ⏳ Web/开放 API 接口
- ⏳ 多种来源接入(飞书、Notion、URL 抓取)
- ⏳ 证据冲突检测
FILE:data/workspace.json
{
"version": 1,
"createdAt": "2026-04-05T00:44:49.471Z",
"updatedAt": "2026-04-05T00:44:49.514Z",
"documents": [
{
"id": "doc_6bb52r",
"type": "document",
"title": "Smoke Test",
"sourceType": "file",
"sourceUri": "/Users/jianghaidong/.openclaw/workspace/agents/coder/skills/linkmind/tests/smoke-test.js",
"importedAt": "2026-04-05T00:44:49.503Z",
"status": "active",
"text": "#!/usr/bin/env node\n/**\n * LinkMind Smoke Test - Phase 2\n * 覆盖: storage adapters + embedding providers + retriever\n */\nimport path from 'path';\nimport { fileURLToPath } from 'url';\nimport { execSync } from 'child_process';\nimport { pathToFileURL } from 'url';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\nconst SKILL_ROOT = path.resolve(__dirname, '..');\nprocess.chdir(SKILL_ROOT);\n\nfunction run(cmd) {\n console.log(`\\n$ cmd`);\n return JSON.parse(execSync(cmd, { encoding: 'utf8' }));\n}\n\nfunction check(label, cond) {\n if (cond) {\n console.log(` ✓ label`);\n } else {\n console.error(` ✗ FAIL: label`);\n process.exitCode = 1;\n }\n}\n\n// Import phase-2 modules via dynamic import\nconst srcDir = path.join(SKILL_ROOT, 'src');\n\nasync function runTests() {\n console.log('=== LinkMind Smoke Test (Phase 2) ===\\n');\n\n // Phase 1: CLI commands\n console.log('--- Phase 1: CLI Commands ---');\n const reset = run('node dist/index.js reset');\n check('reset returns ok', reset.ok === true);\n\n const empty = run('node dist/index.js status');\n check('empty workspace has 0 documents', empty.documents === 0);\n\n const ingest = run('node dist/index.js ingest --file examples/sample-note.md --title \"Sample Note\"');\n check('ingest returns documentId', !!ingest.documentId);\n check('ingest creates fragments', ingest.fragmentsCreated >= 1);\n\n const after = run('node dist/index.js status');\n check('has 1 document', after.documents === 1);\n check('has fragments', after.fragments >= 1);\n\n const q1 = run('node dist/index.js query --q \"knowledge\"');\n check('query \"knowledge\" finds evidence', q1.evidence && q1.evidence.length >= 1);\n check('query \"knowledge\" returns answer', !!q1.answer);\n\n const q2 = run('node dist/index.js query --q \"xyznonexistentterm12345\"');\n check('query no-match returns empty evidence', q2.evidence && q2.evidence.length === 0);\n\n const q3 = run('node dist/index.js query --q \"knowledge\" --limit 1');\n check('query with limit respects limit', q3.evidence && q3.evidence.length <= 1);\n\n // Phase 2: Storage Adapters\n console.log('\\n--- Phase 2: Storage Adapters ---');\n const JsonStorageAdapterMod = await import(pathToFileURL(path.join(srcDir, 'storage-adapters/JsonStorageAdapter.js')).href);\n const StorageAdapterMod = await import(pathToFileURL(path.join(srcDir, 'storage-adapters/StorageAdapter.js')).href);\n const { JsonStorageAdapter } = JsonStorageAdapterMod;\n const { StorageAdapter } = StorageAdapterMod;\n\n const jsAdapter = new JsonStorageAdapter();\n await jsAdapter.init();\n await jsAdapter.clear();\n check('JsonStorageAdapter.init() ok', true);\n\n const testDoc = {\n id: 'doc_test1',\n type: 'document',\n title: 'Test Doc',\n sourceType: 'note',\n sourceUri: '/test/path.md',\n importedAt: new Date().toISOString(),\n status: 'active',\n text: 'This is a test document for storage adapter testing.'\n };\n await jsAdapter.saveDocument(testDoc);\n const retrieved = await jsAdapter.getDocument('doc_test1');\n check('JsonStorageAdapter.saveDocument + getDocument', retrieved && retrieved.title === 'Test Doc');\n\n const docs = await jsAdapter.listDocuments();\n check('JsonStorageAdapter.listDocuments', docs && docs.length >= 1);\n\n const testFrag = {\n id: 'frag_test1',\n type: 'fragment',\n documentId: 'doc_test1',\n index: 0,\n text: 'This is a test fragment.',\n summary: 'This is a test',\n conceptNames: ['test'],\n createdAt: new Date().toISOString()\n };\n await jsAdapter.saveFragment(testFrag);\n const frag = await jsAdapter.getFragment('frag_test1');\n check('JsonStorageAdapter.saveFragment + getFragment', frag && frag.text.includes('test fragment'));\n\n const frags = await jsAdapter.listFragments('doc_test1');\n check('JsonStorageAdapter.listFragments', frags && frags.length >= 1);\n\n const testConcept = {\n id: 'concept_test1',\n type: 'concept',\n name: 'TestConcept',\n normalizedName: 'testconcept',\n salience: 0.5,\n createdAt: new Date().toISOString()\n };\n await jsAdapter.saveConcept(testConcept);\n const concepts = await jsAdapter.listConcepts();\n check('JsonStorageAdapter.listConcepts', concepts && concepts.some(c => c.normalizedName === 'testconcept'));\n\n const testLink = {\n id: 'link_test1',\n type: 'mentions',\n fromId: 'frag_test1',\n fromType: 'fragment',\n toId: 'concept_test1',\n toType: 'concept',\n documentId: 'doc_test1',\n score: 1,\n createdAt: new Date().toISOString()\n };\n await jsAdapter.saveLink(testLink);\n const links = await jsAdapter.getLinks('frag_test1', null);\n check('JsonStorageAdapter.saveLink + getLinks', links && links.some(l => l.id === 'link_test1'));\n\n // test bulk save\n await jsAdapter.clear();\n await jsAdapter.saveDocument(testDoc);\n await jsAdapter.saveFragments([testFrag]);\n await jsAdapter.saveConcepts([testConcept]);\n await jsAdapter.saveLinks([testLink]);\n const afterBulk = await jsAdapter.listFragments('doc_test1');\n check('JsonStorageAdapter.saveFragments bulk', afterBulk && afterBulk.length >= 1);\n\n await jsAdapter.clear();\n const emptyAfterClear = await jsAdapter.listDocuments();\n check('JsonStorageAdapter.clear()', emptyAfterClear.length === 0);\n\n // StorageAdapter interface\n check('StorageAdapter is a class', typeof StorageAdapter === 'function');\n\n // Phase 2: Embedding Providers\n console.log('\\n--- Phase 2: Embedding Providers ---');\n const MockProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/MockProvider.js')).href);\n const OpenAICompatibleProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/OpenAICompatibleProvider.js')).href);\n const EmbeddingProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/EmbeddingProvider.js')).href);\n const { MockProvider } = MockProviderMod;\n const { OpenAICompatibleProvider } = OpenAICompatibleProviderMod;\n const { EmbeddingProvider } = EmbeddingProviderMod;\n\n const mock = new MockProvider({ dimension: 128 });\n check('MockProvider.dimension', mock.dimension === 128);\n check('MockProvider.name', mock.name === 'mock');\n\n const [vec] = await mock.embed(['hello world']);\n check('MockProvider.embed returns vector', Array.isArray(vec) && vec.length === 128);\n check('MockProvider.vector is L2-normalized', Math.abs(vec.reduce((s, v) => s + v * v, 0) - 1) < 0.01);\n\n const [vec2] = await mock.embed(['hello world']);\n check('MockProvider caches results', vec2 === vec);\n\n const differentText = await mock.embed(['different text']);\n check('MockProvider different text yields different vector', differentText[0] !== vec);\n\n const openai = new OpenAICompatibleProvider({ baseURL: 'https://api.example.com/v1', apiKey: 'test-key', model: 'test-model', dimension: 256 });\n check('OpenAICompatibleProvider.dimension', openai.dimension === 256);\n check('OpenAICompatibleProvider.name includes model', openai.name.includes('test-model'));\n check('OpenAICompatibleProvider.name includes baseURL hint', openai.name.includes('openai-compatible'));\n\n check('EmbeddingProvider is a class', typeof EmbeddingProvider === 'function');\n\n // Phase 2: Retriever\n console.log('\\n--- Phase 2: Retriever ---');\n const retrieverMod = await import(pathToFileURL(path.join(srcDir, 'retriever.js')).href);\n const { keywordSearch, vectorSearch, mergeResults, cosineSimilarity, retrieve } = retrieverMod;\n\n check('cosineSimilarity identical vectors = 1', Math.abs(cosineSimilarity([0.5, 0.5, 0.5, 0.5], [0.5, 0.5, 0.5, 0.5]) - 1) < 0.0001);\n check('cosineSimilarity orthogonal = 0', Math.abs(cosineSimilarity([1, 0, 0], [0, 1, 0])) < 0.0001);\n\n const testFrags = [\n { id: 'f1', documentId: 'd1', documentTitle: 'Doc 1', index: 0, text: 'knowledge graph is useful', conceptNames: ['knowledge', 'graph'] },\n { id: 'f2', documentId: 'd1', documentTitle: 'Doc 1', index: 1, text: 'machine learning and AI', conceptNames: ['machine', 'learning'] },\n { id: 'f3', documentId: 'd2', documentTitle: 'Doc 2', index: 0, text: 'knowledge base systems', conceptNames: ['knowledge', 'base'] }\n ];\n\n const kw = keywordSearch(testFrags, 'knowledge');\n check('keywordSearch finds knowledge fragments', kw.length >= 2);\n check('keywordSearch assigns score > 0', kw.every(r => r.score > 0));\n check('keywordSearch source=keyword', kw.every(r => r.source === 'keyword'));\n\n const vecResults = await vectorSearch(testFrags, 'knowledge graph', mock);\n check('vectorSearch returns results', Array.isArray(vecResults));\n check('vectorSearch source=vector', vecResults.every(r => r.source === 'vector'));\n\n const merged = mergeResults(kw, vecResults, 5);\n check('mergeResults deduplicates', merged.length <= kw.length + vecResults.length);\n check('mergeResults returns array', Array.isArray(merged));\n check('mergeResults items have score', merged.every(r => typeof r.score === 'number'));\n\n const kwOnly = await retrieve({ fragments: testFrags, query: 'knowledge', limit: 5 });\n check('retrieve keyword-only works', kwOnly.length >= 1);\n\n const hybrid = await retrieve({ fragments: testFrags, query: 'knowledge', embeddingProvider: mock, limit: 5 });\n check('retrieve hybrid works', hybrid.length >= 1);\n check('retrieve hybrid may include vector source', hybrid.some(r => r.source === 'vector' || r.source === 'hybrid'));\n\n console.log('\\n=== Smoke Test Complete ===');\n if (process.exitCode === 1) {\n console.log('RESULT: FAILED');\n process.exit(1);\n } else {\n console.log('RESULT: PASSED');\n }\n}\n\nrunTests().catch((err) => {\n console.error('Test error:', err);\n process.exit(1);\n});\n"
}
],
"fragments": [
{
"id": "frag_d3g9ts",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 0,
"text": "#!/usr/bin/env node\n/**\n * LinkMind Smoke Test - Phase 2\n * 覆盖: storage adapters + embedding providers + retriever\n */\nimport path from 'path';\nimport { fileURLToPath } from 'url';\nimport { execSync } from 'child_process';\nimport { pathToFileURL } from 'url';",
"summary": "#!/usr/bin/env node\n/**\n * LinkMind Smoke Test - Phase 2\n * 覆盖: storage adapters + embedding provide",
"conceptNames": [
"import",
"path",
"url",
"adapters",
"bin",
"child_process"
]
},
{
"id": "frag_pmaij6",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 1,
"text": "const __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\nconst SKILL_ROOT = path.resolve(__dirname, '..');\nprocess.chdir(SKILL_ROOT);",
"summary": "const __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\nconst",
"conceptNames": [
"const",
"dirname",
"filename",
"path",
"skill_root",
"chdir"
]
},
{
"id": "frag_dw2gxf",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 2,
"text": "function run(cmd) {\n console.log(`\\n$ cmd`);\n return JSON.parse(execSync(cmd, { encoding: 'utf8' }));\n}",
"summary": "function run(cmd) {\n console.log(`\\n$ cmd`);\n return JSON.parse(execSync(cmd, { encoding: 'utf8",
"conceptNames": [
"cmd",
"console",
"encoding",
"execsync",
"function",
"json"
]
},
{
"id": "frag_1qvdbx",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 3,
"text": "function check(label, cond) {\n if (cond) {\n console.log(` ✓ label`);\n } else {\n console.error(` ✗ FAIL: label`);\n process.exitCode = 1;\n }\n}",
"summary": "function check(label, cond) {\n if (cond) {\n console.log(` ✓ label`);\n } else {\n console.",
"conceptNames": [
"label",
"cond",
"console",
"check",
"else",
"error"
]
},
{
"id": "frag_2pvrfi",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 4,
"text": "// Import phase-2 modules via dynamic import\nconst srcDir = path.join(SKILL_ROOT, 'src');",
"summary": "// Import phase-2 modules via dynamic import\nconst srcDir = path.join(SKILL_ROOT, 'src');",
"conceptNames": [
"import",
"const",
"dynamic",
"join",
"modules",
"path"
]
},
{
"id": "frag_49spg6",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 5,
"text": "async function runTests() {\n console.log('=== LinkMind Smoke Test (Phase 2) ===\\n');",
"summary": "async function runTests() {\n console.log('=== LinkMind Smoke Test (Phase 2) ===\\n');",
"conceptNames": [
"async",
"console",
"function",
"linkmind",
"log",
"phase"
]
},
{
"id": "frag_l7f27m",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 6,
"text": "// Phase 1: CLI commands\n console.log('--- Phase 1: CLI Commands ---');\n const reset = run('node dist/index.js reset');\n check('reset returns ok', reset.ok === true);",
"summary": "// Phase 1: CLI commands\n console.log('--- Phase 1: CLI Commands ---');\n const reset = run('node d",
"conceptNames": [
"reset",
"cli",
"commands",
"phase",
"check",
"console"
]
},
{
"id": "frag_4yjic",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 7,
"text": "const empty = run('node dist/index.js status');\n check('empty workspace has 0 documents', empty.documents === 0);",
"summary": "const empty = run('node dist/index.js status');\n check('empty workspace has 0 documents', empty.doc",
"conceptNames": [
"empty",
"documents",
"check",
"const",
"dist",
"index"
]
},
{
"id": "frag_qhu1w0",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 8,
"text": "const ingest = run('node dist/index.js ingest --file examples/sample-note.md --title \"Sample Note\"');\n check('ingest returns documentId', !!ingest.documentId);\n check('ingest creates fragments', ingest.fragmentsCreated >= 1);",
"summary": "const ingest = run('node dist/index.js ingest --file examples/sample-note.md --title \"Sample Note\"')",
"conceptNames": [
"ingest",
"check",
"documentid",
"const",
"creates",
"dist"
]
},
{
"id": "frag_3vvxiw",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 9,
"text": "const after = run('node dist/index.js status');\n check('has 1 document', after.documents === 1);\n check('has fragments', after.fragments >= 1);",
"summary": "const after = run('node dist/index.js status');\n check('has 1 document', after.documents === 1);\n ",
"conceptNames": [
"after",
"check",
"fragments",
"const",
"dist",
"document"
]
},
{
"id": "frag_o5huex",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 10,
"text": "const q1 = run('node dist/index.js query --q \"knowledge\"');\n check('query \"knowledge\" finds evidence', q1.evidence && q1.evidence.length >= 1);\n check('query \"knowledge\" returns answer', !!q1.answer);",
"summary": "const q1 = run('node dist/index.js query --q \"knowledge\"');\n check('query \"knowledge\" finds evidenc",
"conceptNames": [
"evidence",
"knowledge",
"query",
"answer",
"check",
"const"
]
},
{
"id": "frag_yice37",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 11,
"text": "const q2 = run('node dist/index.js query --q \"xyznonexistentterm12345\"');\n check('query no-match returns empty evidence', q2.evidence && q2.evidence.length === 0);",
"summary": "const q2 = run('node dist/index.js query --q \"xyznonexistentterm12345\"');\n check('query no-match re",
"conceptNames": [
"evidence",
"query",
"check",
"const",
"dist",
"empty"
]
},
{
"id": "frag_qso8rl",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 12,
"text": "const q3 = run('node dist/index.js query --q \"knowledge\" --limit 1');\n check('query with limit respects limit', q3.evidence && q3.evidence.length <= 1);",
"summary": "const q3 = run('node dist/index.js query --q \"knowledge\" --limit 1');\n check('query with limit resp",
"conceptNames": [
"limit",
"evidence",
"query",
"check",
"const",
"dist"
]
},
{
"id": "frag_83cldn",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 13,
"text": "// Phase 2: Storage Adapters\n console.log('\\n--- Phase 2: Storage Adapters ---');\n const JsonStorageAdapterMod = await import(pathToFileURL(path.join(srcDir, 'storage-adapters/JsonStorageAdapter.js')).href);\n const StorageAdapterMod = await import(pathToFileURL(path.join(srcDir, 'storage-adapters/StorageAdapter.js')).href);\n const { JsonStorageAdapter } = JsonStorageAdapterMod;\n const { StorageAdapter } = StorageAdapterMod;",
"summary": "// Phase 2: Storage Adapters\n console.log('\\n--- Phase 2: Storage Adapters ---');\n const JsonStora",
"conceptNames": [
"const",
"adapters",
"await",
"href",
"import",
"join"
]
},
{
"id": "frag_ou8eku",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 14,
"text": "const jsAdapter = new JsonStorageAdapter();\n await jsAdapter.init();\n await jsAdapter.clear();\n check('JsonStorageAdapter.init() ok', true);",
"summary": "const jsAdapter = new JsonStorageAdapter();\n await jsAdapter.init();\n await jsAdapter.clear();\n c",
"conceptNames": [
"jsadapter",
"await",
"init",
"jsonstorageadapter",
"check",
"clear"
]
},
{
"id": "frag_mjx4px",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 15,
"text": "const testDoc = {\n id: 'doc_test1',\n type: 'document',\n title: 'Test Doc',\n sourceType: 'note',\n sourceUri: '/test/path.md',\n importedAt: new Date().toISOString(),\n status: 'active',\n text: 'This is a test document for storage adapter testing.'\n };\n await jsAdapter.saveDocument(testDoc);\n const retrieved = await jsAdapter.getDocument('doc_test1');\n check('JsonStorageAdapter.saveDocument + getDocument', retrieved && retrieved.title === 'Test Doc');",
"summary": "const testDoc = {\n id: 'doc_test1',\n type: 'document',\n title: 'Test Doc',\n sourceType: ",
"conceptNames": [
"test",
"retrieved",
"await",
"const",
"doc",
"doc_test1"
]
},
{
"id": "frag_xgccse",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 16,
"text": "const docs = await jsAdapter.listDocuments();\n check('JsonStorageAdapter.listDocuments', docs && docs.length >= 1);",
"summary": "const docs = await jsAdapter.listDocuments();\n check('JsonStorageAdapter.listDocuments', docs && do",
"conceptNames": [
"docs",
"listdocuments",
"await",
"check",
"const",
"jsadapter"
]
},
{
"id": "frag_4t3zpb",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 17,
"text": "const testFrag = {\n id: 'frag_test1',\n type: 'fragment',\n documentId: 'doc_test1',\n index: 0,\n text: 'This is a test fragment.',\n summary: 'This is a test',\n conceptNames: ['test'],\n createdAt: new Date().toISOString()\n };\n await jsAdapter.saveFragment(testFrag);\n const frag = await jsAdapter.getFragment('frag_test1');\n check('JsonStorageAdapter.saveFragment + getFragment', frag && frag.text.includes('test fragment'));",
"summary": "const testFrag = {\n id: 'frag_test1',\n type: 'fragment',\n documentId: 'doc_test1',\n inde",
"conceptNames": [
"test",
"frag",
"fragment",
"await",
"const",
"frag_test1"
]
},
{
"id": "frag_g3bxss",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 18,
"text": "const frags = await jsAdapter.listFragments('doc_test1');\n check('JsonStorageAdapter.listFragments', frags && frags.length >= 1);",
"summary": "const frags = await jsAdapter.listFragments('doc_test1');\n check('JsonStorageAdapter.listFragments'",
"conceptNames": [
"frags",
"listfragments",
"await",
"check",
"const",
"doc_test1"
]
},
{
"id": "frag_g30jd6",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 19,
"text": "const testConcept = {\n id: 'concept_test1',\n type: 'concept',\n name: 'TestConcept',\n normalizedName: 'testconcept',\n salience: 0.5,\n createdAt: new Date().toISOString()\n };\n await jsAdapter.saveConcept(testConcept);\n const concepts = await jsAdapter.listConcepts();\n check('JsonStorageAdapter.listConcepts', concepts && concepts.some(c => c.normalizedName === 'testconcept'));",
"summary": "const testConcept = {\n id: 'concept_test1',\n type: 'concept',\n name: 'TestConcept',\n nor",
"conceptNames": [
"testconcept",
"concepts",
"await",
"const",
"jsadapter",
"listconcepts"
]
},
{
"id": "frag_h3k825",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 20,
"text": "const testLink = {\n id: 'link_test1',\n type: 'mentions',\n fromId: 'frag_test1',\n fromType: 'fragment',\n toId: 'concept_test1',\n toType: 'concept',\n documentId: 'doc_test1',\n score: 1,\n createdAt: new Date().toISOString()\n };\n await jsAdapter.saveLink(testLink);\n const links = await jsAdapter.getLinks('frag_test1', null);\n check('JsonStorageAdapter.saveLink + getLinks', links && links.some(l => l.id === 'link_test1'));",
"summary": "const testLink = {\n id: 'link_test1',\n type: 'mentions',\n fromId: 'frag_test1',\n fromTyp",
"conceptNames": [
"links",
"await",
"const",
"frag_test1",
"getlinks",
"jsadapter"
]
},
{
"id": "frag_ivfanb",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 21,
"text": "// test bulk save\n await jsAdapter.clear();\n await jsAdapter.saveDocument(testDoc);\n await jsAdapter.saveFragments([testFrag]);\n await jsAdapter.saveConcepts([testConcept]);\n await jsAdapter.saveLinks([testLink]);\n const afterBulk = await jsAdapter.listFragments('doc_test1');\n check('JsonStorageAdapter.saveFragments bulk', afterBulk && afterBulk.length >= 1);",
"summary": "// test bulk save\n await jsAdapter.clear();\n await jsAdapter.saveDocument(testDoc);\n await jsAdap",
"conceptNames": [
"await",
"jsadapter",
"afterbulk",
"bulk",
"savefragments",
"check"
]
},
{
"id": "frag_g1qshr",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 22,
"text": "await jsAdapter.clear();\n const emptyAfterClear = await jsAdapter.listDocuments();\n check('JsonStorageAdapter.clear()', emptyAfterClear.length === 0);",
"summary": "await jsAdapter.clear();\n const emptyAfterClear = await jsAdapter.listDocuments();\n check('JsonSto",
"conceptNames": [
"await",
"clear",
"emptyafterclear",
"jsadapter",
"check",
"const"
]
},
{
"id": "frag_m2z8gs",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 23,
"text": "// StorageAdapter interface\n check('StorageAdapter is a class', typeof StorageAdapter === 'function');",
"summary": "// StorageAdapter interface\n check('StorageAdapter is a class', typeof StorageAdapter === 'function",
"conceptNames": [
"storageadapter",
"check",
"class",
"function",
"interface",
"typeof"
]
},
{
"id": "frag_la8iqs",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 24,
"text": "// Phase 2: Embedding Providers\n console.log('\\n--- Phase 2: Embedding Providers ---');\n const MockProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/MockProvider.js')).href);\n const OpenAICompatibleProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/OpenAICompatibleProvider.js')).href);\n const EmbeddingProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/EmbeddingProvider.js')).href);\n const { MockProvider } = MockProviderMod;\n const { OpenAICompatibleProvider } = OpenAICompatibleProviderMod;\n const { EmbeddingProvider } = EmbeddingProviderMod;",
"summary": "// Phase 2: Embedding Providers\n console.log('\\n--- Phase 2: Embedding Providers ---');\n const Moc",
"conceptNames": [
"const",
"await",
"embedding-providers",
"href",
"import",
"join"
]
},
{
"id": "frag_k2wcc",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 25,
"text": "const mock = new MockProvider({ dimension: 128 });\n check('MockProvider.dimension', mock.dimension === 128);\n check('MockProvider.name', mock.name === 'mock');",
"summary": "const mock = new MockProvider({ dimension: 128 });\n check('MockProvider.dimension', mock.dimension ",
"conceptNames": [
"mock",
"dimension",
"mockprovider",
"check",
"name",
"const"
]
},
{
"id": "frag_amqxax",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 26,
"text": "const [vec] = await mock.embed(['hello world']);\n check('MockProvider.embed returns vector', Array.isArray(vec) && vec.length === 128);\n check('MockProvider.vector is L2-normalized', Math.abs(vec.reduce((s, v) => s + v * v, 0) - 1) < 0.01);",
"summary": "const [vec] = await mock.embed(['hello world']);\n check('MockProvider.embed returns vector', Array.",
"conceptNames": [
"vec",
"check",
"embed",
"mockprovider",
"vector",
"abs"
]
},
{
"id": "frag_k3j5ot",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 27,
"text": "const [vec2] = await mock.embed(['hello world']);\n check('MockProvider caches results', vec2 === vec);",
"summary": "const [vec2] = await mock.embed(['hello world']);\n check('MockProvider caches results', vec2 === ve",
"conceptNames": [
"vec2",
"await",
"caches",
"check",
"const",
"embed"
]
},
{
"id": "frag_oxk0al",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 28,
"text": "const differentText = await mock.embed(['different text']);\n check('MockProvider different text yields different vector', differentText[0] !== vec);",
"summary": "const differentText = await mock.embed(['different text']);\n check('MockProvider different text yie",
"conceptNames": [
"different",
"differenttext",
"text",
"await",
"check",
"const"
]
},
{
"id": "frag_w7ug9l",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 29,
"text": "const openai = new OpenAICompatibleProvider({ baseURL: 'https://api.example.com/v1', apiKey: 'test-key', model: 'test-model', dimension: 256 });\n check('OpenAICompatibleProvider.dimension', openai.dimension === 256);\n check('OpenAICompatibleProvider.name includes model', openai.name.includes('test-model'));\n check('OpenAICompatibleProvider.name includes baseURL hint', openai.name.includes('openai-compatible'));",
"summary": "const openai = new OpenAICompatibleProvider({ baseURL: 'https://api.example.com/v1', apiKey: 'test-k",
"conceptNames": [
"includes",
"name",
"openai",
"openaicompatibleprovider",
"check",
"dimension"
]
},
{
"id": "frag_h1uzlu",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 30,
"text": "check('EmbeddingProvider is a class', typeof EmbeddingProvider === 'function');",
"summary": "check('EmbeddingProvider is a class', typeof EmbeddingProvider === 'function');",
"conceptNames": [
"embeddingprovider",
"check",
"class",
"function",
"typeof"
]
},
{
"id": "frag_rrcn8h",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 31,
"text": "// Phase 2: Retriever\n console.log('\\n--- Phase 2: Retriever ---');\n const retrieverMod = await import(pathToFileURL(path.join(srcDir, 'retriever.js')).href);\n const { keywordSearch, vectorSearch, mergeResults, cosineSimilarity, retrieve } = retrieverMod;",
"summary": "// Phase 2: Retriever\n console.log('\\n--- Phase 2: Retriever ---');\n const retrieverMod = await im",
"conceptNames": [
"retriever",
"const",
"phase",
"retrievermod",
"await",
"console"
]
},
{
"id": "frag_z6ich8",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 32,
"text": "check('cosineSimilarity identical vectors = 1', Math.abs(cosineSimilarity([0.5, 0.5, 0.5, 0.5], [0.5, 0.5, 0.5, 0.5]) - 1) < 0.0001);\n check('cosineSimilarity orthogonal = 0', Math.abs(cosineSimilarity([1, 0, 0], [0, 1, 0])) < 0.0001);",
"summary": "check('cosineSimilarity identical vectors = 1', Math.abs(cosineSimilarity([0.5, 0.5, 0.5, 0.5], [0.5",
"conceptNames": [
"cosinesimilarity",
"abs",
"check",
"math",
"identical",
"orthogonal"
]
},
{
"id": "frag_5i1o5v",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 33,
"text": "const testFrags = [\n { id: 'f1', documentId: 'd1', documentTitle: 'Doc 1', index: 0, text: 'knowledge graph is useful', conceptNames: ['knowledge', 'graph'] },\n { id: 'f2', documentId: 'd1', documentTitle: 'Doc 1', index: 1, text: 'machine learning and AI', conceptNames: ['machine', 'learning'] },\n { id: 'f3', documentId: 'd2', documentTitle: 'Doc 2', index: 0, text: 'knowledge base systems', conceptNames: ['knowledge', 'base'] }\n ];",
"summary": "const testFrags = [\n { id: 'f1', documentId: 'd1', documentTitle: 'Doc 1', index: 0, text: 'knowl",
"conceptNames": [
"knowledge",
"conceptnames",
"doc",
"documentid",
"documenttitle",
"index"
]
},
{
"id": "frag_1wv3le",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 34,
"text": "const kw = keywordSearch(testFrags, 'knowledge');\n check('keywordSearch finds knowledge fragments', kw.length >= 2);\n check('keywordSearch assigns score > 0', kw.every(r => r.score > 0));\n check('keywordSearch source=keyword', kw.every(r => r.source === 'keyword'));",
"summary": "const kw = keywordSearch(testFrags, 'knowledge');\n check('keywordSearch finds knowledge fragments',",
"conceptNames": [
"keywordsearch",
"check",
"every",
"keyword",
"knowledge",
"score"
]
},
{
"id": "frag_jx95go",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 35,
"text": "const vecResults = await vectorSearch(testFrags, 'knowledge graph', mock);\n check('vectorSearch returns results', Array.isArray(vecResults));\n check('vectorSearch source=vector', vecResults.every(r => r.source === 'vector'));",
"summary": "const vecResults = await vectorSearch(testFrags, 'knowledge graph', mock);\n check('vectorSearch ret",
"conceptNames": [
"vecresults",
"vectorsearch",
"check",
"source",
"vector",
"array"
]
},
{
"id": "frag_mb8wqq",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 36,
"text": "const merged = mergeResults(kw, vecResults, 5);\n check('mergeResults deduplicates', merged.length <= kw.length + vecResults.length);\n check('mergeResults returns array', Array.isArray(merged));\n check('mergeResults items have score', merged.every(r => typeof r.score === 'number'));",
"summary": "const merged = mergeResults(kw, vecResults, 5);\n check('mergeResults deduplicates', merged.length <",
"conceptNames": [
"merged",
"mergeresults",
"check",
"length",
"array",
"score"
]
},
{
"id": "frag_c2gwcq",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 37,
"text": "const kwOnly = await retrieve({ fragments: testFrags, query: 'knowledge', limit: 5 });\n check('retrieve keyword-only works', kwOnly.length >= 1);",
"summary": "const kwOnly = await retrieve({ fragments: testFrags, query: 'knowledge', limit: 5 });\n check('retr",
"conceptNames": [
"kwonly",
"retrieve",
"await",
"check",
"const",
"fragments"
]
},
{
"id": "frag_lybfjt",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 38,
"text": "const hybrid = await retrieve({ fragments: testFrags, query: 'knowledge', embeddingProvider: mock, limit: 5 });\n check('retrieve hybrid works', hybrid.length >= 1);\n check('retrieve hybrid may include vector source', hybrid.some(r => r.source === 'vector' || r.source === 'hybrid'));",
"summary": "const hybrid = await retrieve({ fragments: testFrags, query: 'knowledge', embeddingProvider: mock, l",
"conceptNames": [
"hybrid",
"retrieve",
"source",
"check",
"vector",
"await"
]
},
{
"id": "frag_4nve2i",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 39,
"text": "console.log('\\n=== Smoke Test Complete ===');\n if (process.exitCode === 1) {\n console.log('RESULT: FAILED');\n process.exit(1);\n } else {\n console.log('RESULT: PASSED');\n }\n}",
"summary": "console.log('\\n=== Smoke Test Complete ===');\n if (process.exitCode === 1) {\n console.log('RESUL",
"conceptNames": [
"console",
"log",
"process",
"result",
"complete",
"else"
]
},
{
"id": "frag_h74lv",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 40,
"text": "runTests().catch((err) => {\n console.error('Test error:', err);\n process.exit(1);\n});",
"summary": "runTests().catch((err) => {\n console.error('Test error:', err);\n process.exit(1);\n});",
"conceptNames": [
"err",
"error",
"catch",
"console",
"exit",
"process"
]
}
],
"concepts": [
{
"id": "concept_22cy",
"type": "concept",
"name": "abs",
"normalizedName": "abs",
"salience": 0.3333333333333333
},
{
"id": "concept_llkge4",
"type": "concept",
"name": "adapters",
"normalizedName": "adapters",
"salience": 0.3333333333333333
},
{
"id": "concept_1j7mqk",
"type": "concept",
"name": "after",
"normalizedName": "after",
"salience": 1
},
{
"id": "concept_gv37qm",
"type": "concept",
"name": "afterBulk",
"normalizedName": "afterbulk",
"salience": 1
},
{
"id": "concept_nd5e82",
"type": "concept",
"name": "answer",
"normalizedName": "answer",
"salience": 0.6666666666666666
},
{
"id": "concept_1jf909",
"type": "concept",
"name": "Array",
"normalizedName": "array",
"salience": 0.3333333333333333
},
{
"id": "concept_1jg1h8",
"type": "concept",
"name": "async",
"normalizedName": "async",
"salience": 0.3333333333333333
},
{
"id": "concept_1ji3iu",
"type": "concept",
"name": "await",
"normalizedName": "await",
"salience": 0.6666666666666666
},
{
"id": "concept_239j",
"type": "concept",
"name": "bin",
"normalizedName": "bin",
"salience": 0.3333333333333333
},
{
"id": "concept_1t24y",
"type": "concept",
"name": "bulk",
"normalizedName": "bulk",
"salience": 0.6666666666666666
},
{
"id": "concept_mmi027",
"type": "concept",
"name": "caches",
"normalizedName": "caches",
"salience": 0.3333333333333333
},
{
"id": "concept_1k80xn",
"type": "concept",
"name": "catch",
"normalizedName": "catch",
"salience": 0.3333333333333333
},
{
"id": "concept_1kc64o",
"type": "concept",
"name": "chdir",
"normalizedName": "chdir",
"salience": 0.3333333333333333
},
{
"id": "concept_1kc6q0",
"type": "concept",
"name": "check",
"normalizedName": "check",
"salience": 0.3333333333333333
},
{
"id": "concept_86zfzo",
"type": "concept",
"name": "child_process",
"normalizedName": "child_process",
"salience": 0.3333333333333333
},
{
"id": "concept_1keo3c",
"type": "concept",
"name": "class",
"normalizedName": "class",
"salience": 0.3333333333333333
},
{
"id": "concept_1keqml",
"type": "concept",
"name": "clear",
"normalizedName": "clear",
"salience": 0.3333333333333333
},
{
"id": "concept_242o",
"type": "concept",
"name": "CLI",
"normalizedName": "cli",
"salience": 0.6666666666666666
},
{
"id": "concept_243e",
"type": "concept",
"name": "cmd",
"normalizedName": "cmd",
"salience": 1
},
{
"id": "concept_9yqf7c",
"type": "concept",
"name": "commands",
"normalizedName": "commands",
"salience": 0.6666666666666666
},
{
"id": "concept_9ww6vb",
"type": "concept",
"name": "Complete",
"normalizedName": "complete",
"salience": 0.3333333333333333
},
{
"id": "concept_gr1mao",
"type": "concept",
"name": "conceptNames",
"normalizedName": "conceptnames",
"salience": 1
},
{
"id": "concept_9n44xh",
"type": "concept",
"name": "concepts",
"normalizedName": "concepts",
"salience": 1
},
{
"id": "concept_1tkpu",
"type": "concept",
"name": "cond",
"normalizedName": "cond",
"salience": 0.6666666666666666
},
{
"id": "concept_fqi63b",
"type": "concept",
"name": "console",
"normalizedName": "console",
"salience": 0.3333333333333333
},
{
"id": "concept_1kguoz",
"type": "concept",
"name": "const",
"normalizedName": "const",
"salience": 1
},
{
"id": "concept_hkekis",
"type": "concept",
"name": "cosineSimilarity",
"normalizedName": "cosinesimilarity",
"salience": 1
},
{
"id": "concept_h0dhpz",
"type": "concept",
"name": "creates",
"normalizedName": "creates",
"salience": 0.3333333333333333
},
{
"id": "concept_ljky0p",
"type": "concept",
"name": "different",
"normalizedName": "different",
"salience": 1
},
{
"id": "concept_q1bzdy",
"type": "concept",
"name": "differentText",
"normalizedName": "differenttext",
"salience": 0.6666666666666666
},
{
"id": "concept_i3xxga",
"type": "concept",
"name": "dimension",
"normalizedName": "dimension",
"salience": 1
},
{
"id": "concept_rmqf14",
"type": "concept",
"name": "dirname",
"normalizedName": "dirname",
"salience": 1
},
{
"id": "concept_1u3dy",
"type": "concept",
"name": "dist",
"normalizedName": "dist",
"salience": 0.3333333333333333
},
{
"id": "concept_24vs",
"type": "concept",
"name": "Doc",
"normalizedName": "doc",
"salience": 0.6666666666666666
},
{
"id": "concept_5amdjs",
"type": "concept",
"name": "doc_test1",
"normalizedName": "doc_test1",
"salience": 0.6666666666666666
},
{
"id": "concept_1u7gb",
"type": "concept",
"name": "docs",
"normalizedName": "docs",
"salience": 1
},
{
"id": "concept_e91o2j",
"type": "concept",
"name": "document",
"normalizedName": "document",
"salience": 0.3333333333333333
},
{
"id": "concept_dh6z62",
"type": "concept",
"name": "documentId",
"normalizedName": "documentid",
"salience": 0.6666666666666666
},
{
"id": "concept_flreew",
"type": "concept",
"name": "documents",
"normalizedName": "documents",
"salience": 0.6666666666666666
},
{
"id": "concept_qjcwf1",
"type": "concept",
"name": "documentTitle",
"normalizedName": "documenttitle",
"salience": 1
},
{
"id": "concept_z514xr",
"type": "concept",
"name": "dynamic",
"normalizedName": "dynamic",
"salience": 0.3333333333333333
},
{
"id": "concept_1usl5",
"type": "concept",
"name": "else",
"normalizedName": "else",
"salience": 0.3333333333333333
},
{
"id": "concept_1liwnt",
"type": "concept",
"name": "embed",
"normalizedName": "embed",
"salience": 0.6666666666666666
},
{
"id": "concept_7liuz8",
"type": "concept",
"name": "embedding-providers",
"normalizedName": "embedding-providers",
"salience": 1
},
{
"id": "concept_w9ow8",
"type": "concept",
"name": "EmbeddingProvider",
"normalizedName": "embeddingprovider",
"salience": 0.6666666666666666
},
{
"id": "concept_1lj7f1",
"type": "concept",
"name": "empty",
"normalizedName": "empty",
"salience": 1
},
{
"id": "concept_dlzsam",
"type": "concept",
"name": "emptyAfterClear",
"normalizedName": "emptyafterclear",
"salience": 0.6666666666666666
},
{
"id": "concept_satff7",
"type": "concept",
"name": "encoding",
"normalizedName": "encoding",
"salience": 0.3333333333333333
},
{
"id": "concept_25ph",
"type": "concept",
"name": "err",
"normalizedName": "err",
"salience": 0.6666666666666666
},
{
"id": "concept_1lmfpk",
"type": "concept",
"name": "error",
"normalizedName": "error",
"salience": 0.3333333333333333
},
{
"id": "concept_1loq3f",
"type": "concept",
"name": "every",
"normalizedName": "every",
"salience": 0.6666666666666666
},
{
"id": "concept_6c0biv",
"type": "concept",
"name": "evidence",
"normalizedName": "evidence",
"salience": 1
},
{
"id": "concept_xsbs18",
"type": "concept",
"name": "execSync",
"normalizedName": "execsync",
"salience": 0.3333333333333333
},
{
"id": "concept_1v19a",
"type": "concept",
"name": "exit",
"normalizedName": "exit",
"salience": 0.3333333333333333
},
{
"id": "concept_c5gn3t",
"type": "concept",
"name": "filename",
"normalizedName": "filename",
"salience": 0.6666666666666666
},
{
"id": "concept_1vjle",
"type": "concept",
"name": "frag",
"normalizedName": "frag",
"salience": 1
},
{
"id": "concept_o1b6i6",
"type": "concept",
"name": "frag_test1",
"normalizedName": "frag_test1",
"salience": 0.6666666666666666
},
{
"id": "concept_raj06o",
"type": "concept",
"name": "fragment",
"normalizedName": "fragment",
"salience": 1
},
{
"id": "concept_6azi1v",
"type": "concept",
"name": "fragments",
"normalizedName": "fragments",
"salience": 0.6666666666666666
},
{
"id": "concept_1m5vi9",
"type": "concept",
"name": "frags",
"normalizedName": "frags",
"salience": 1
},
{
"id": "concept_mu6b4o",
"type": "concept",
"name": "function",
"normalizedName": "function",
"salience": 0.3333333333333333
},
{
"id": "concept_wvtd0j",
"type": "concept",
"name": "getLinks",
"normalizedName": "getlinks",
"salience": 0.6666666666666666
},
{
"id": "concept_1wtnv",
"type": "concept",
"name": "href",
"normalizedName": "href",
"salience": 0.6666666666666666
},
{
"id": "concept_jw39c4",
"type": "concept",
"name": "hybrid",
"normalizedName": "hybrid",
"salience": 1
},
{
"id": "concept_1fehth",
"type": "concept",
"name": "identical",
"normalizedName": "identical",
"salience": 0.3333333333333333
},
{
"id": "concept_jlea8r",
"type": "concept",
"name": "import",
"normalizedName": "import",
"salience": 1
},
{
"id": "concept_1hqksr",
"type": "concept",
"name": "includes",
"normalizedName": "includes",
"salience": 1
},
{
"id": "concept_1nqriq",
"type": "concept",
"name": "index",
"normalizedName": "index",
"salience": 0.3333333333333333
},
{
"id": "concept_jl0fx8",
"type": "concept",
"name": "ingest",
"normalizedName": "ingest",
"salience": 1
},
{
"id": "concept_1xdsg",
"type": "concept",
"name": "init",
"normalizedName": "init",
"salience": 0.6666666666666666
},
{
"id": "concept_8b8yt5",
"type": "concept",
"name": "interface",
"normalizedName": "interface",
"salience": 0.3333333333333333
},
{
"id": "concept_1y1ii",
"type": "concept",
"name": "join",
"normalizedName": "join",
"salience": 0.3333333333333333
},
{
"id": "concept_f09bfe",
"type": "concept",
"name": "jsAdapter",
"normalizedName": "jsadapter",
"salience": 1
},
{
"id": "concept_1y4mg",
"type": "concept",
"name": "JSON",
"normalizedName": "json",
"salience": 0.3333333333333333
},
{
"id": "concept_b0w3to",
"type": "concept",
"name": "JsonStorageAdapter",
"normalizedName": "jsonstorageadapter",
"salience": 0.6666666666666666
},
{
"id": "concept_dgvlef",
"type": "concept",
"name": "keyword",
"normalizedName": "keyword",
"salience": 0.6666666666666666
},
{
"id": "concept_ofkd9r",
"type": "concept",
"name": "keywordSearch",
"normalizedName": "keywordsearch",
"salience": 1
},
{
"id": "concept_pmrgxq",
"type": "concept",
"name": "knowledge",
"normalizedName": "knowledge",
"salience": 1
},
{
"id": "concept_ihtre0",
"type": "concept",
"name": "kwOnly",
"normalizedName": "kwonly",
"salience": 0.6666666666666666
},
{
"id": "concept_1p5sz8",
"type": "concept",
"name": "label",
"normalizedName": "label",
"salience": 1
},
{
"id": "concept_iap7oa",
"type": "concept",
"name": "length",
"normalizedName": "length",
"salience": 1
},
{
"id": "concept_1pb54r",
"type": "concept",
"name": "limit",
"normalizedName": "limit",
"salience": 1
},
{
"id": "concept_jrfmmk",
"type": "concept",
"name": "LinkMind",
"normalizedName": "linkmind",
"salience": 0.3333333333333333
},
{
"id": "concept_1pb5x5",
"type": "concept",
"name": "links",
"normalizedName": "links",
"salience": 1
},
{
"id": "concept_77o2i1",
"type": "concept",
"name": "listConcepts",
"normalizedName": "listconcepts",
"salience": 0.6666666666666666
},
{
"id": "concept_u4un1i",
"type": "concept",
"name": "listDocuments",
"normalizedName": "listdocuments",
"salience": 0.6666666666666666
},
{
"id": "concept_vlhikl",
"type": "concept",
"name": "listFragments",
"normalizedName": "listfragments",
"salience": 0.6666666666666666
},
{
"id": "concept_2atg",
"type": "concept",
"name": "log",
"normalizedName": "log",
"salience": 0.3333333333333333
},
{
"id": "concept_1zoco",
"type": "concept",
"name": "Math",
"normalizedName": "math",
"salience": 0.6666666666666666
},
{
"id": "concept_htl1p0",
"type": "concept",
"name": "merged",
"normalizedName": "merged",
"salience": 1
},
{
"id": "concept_ljhtmq",
"type": "concept",
"name": "mergeResults",
"normalizedName": "mergeresults",
"salience": 1
},
{
"id": "concept_1zybu",
"type": "concept",
"name": "mock",
"normalizedName": "mock",
"salience": 1
},
{
"id": "concept_3bbkvv",
"type": "concept",
"name": "MockProvider",
"normalizedName": "mockprovider",
"salience": 1
},
{
"id": "concept_kas613",
"type": "concept",
"name": "modules",
"normalizedName": "modules",
"salience": 0.3333333333333333
},
{
"id": "concept_20b63",
"type": "concept",
"name": "name",
"normalizedName": "name",
"salience": 0.6666666666666666
},
{
"id": "concept_gpo83y",
"type": "concept",
"name": "openai",
"normalizedName": "openai",
"salience": 1
},
{
"id": "concept_cpps5l",
"type": "concept",
"name": "OpenAICompatibleProvider",
"normalizedName": "openaicompatibleprovider",
"salience": 1
},
{
"id": "concept_j6wxa1",
"type": "concept",
"name": "orthogonal",
"normalizedName": "orthogonal",
"salience": 0.3333333333333333
},
{
"id": "concept_21lb9",
"type": "concept",
"name": "path",
"normalizedName": "path",
"salience": 0.6666666666666666
},
{
"id": "concept_1rhfuj",
"type": "concept",
"name": "Phase",
"normalizedName": "phase",
"salience": 0.3333333333333333
},
{
"id": "concept_54a26p",
"type": "concept",
"name": "process",
"normalizedName": "process",
"salience": 0.6666666666666666
},
{
"id": "concept_1s9m88",
"type": "concept",
"name": "query",
"normalizedName": "query",
"salience": 1
},
{
"id": "concept_1sjh3j",
"type": "concept",
"name": "reset",
"normalizedName": "reset",
"salience": 1
},
{
"id": "concept_fgc06b",
"type": "concept",
"name": "RESULT",
"normalizedName": "result",
"salience": 0.6666666666666666
},
{
"id": "concept_54l41w",
"type": "concept",
"name": "retrieve",
"normalizedName": "retrieve",
"salience": 0.6666666666666666
},
{
"id": "concept_gvydlk",
"type": "concept",
"name": "retrieved",
"normalizedName": "retrieved",
"salience": 1
},
{
"id": "concept_gvydl6",
"type": "concept",
"name": "Retriever",
"normalizedName": "retriever",
"salience": 1
},
{
"id": "concept_cbkjnw",
"type": "concept",
"name": "retrieverMod",
"normalizedName": "retrievermod",
"salience": 0.6666666666666666
},
{
"id": "concept_b6ebeu",
"type": "concept",
"name": "saveFragments",
"normalizedName": "savefragments",
"salience": 0.6666666666666666
},
{
"id": "concept_1t1x1u",
"type": "concept",
"name": "score",
"normalizedName": "score",
"salience": 0.6666666666666666
},
{
"id": "concept_ql1wy8",
"type": "concept",
"name": "SKILL_ROOT",
"normalizedName": "skill_root",
"salience": 0.6666666666666666
},
{
"id": "concept_etr8bp",
"type": "concept",
"name": "source",
"normalizedName": "source",
"salience": 0.6666666666666666
},
{
"id": "concept_7wml4k",
"type": "concept",
"name": "StorageAdapter",
"normalizedName": "storageadapter",
"salience": 1
},
{
"id": "concept_2487m",
"type": "concept",
"name": "Test",
"normalizedName": "test",
"salience": 1
},
{
"id": "concept_iamaui",
"type": "concept",
"name": "testConcept",
"normalizedName": "testconcept",
"salience": 1
},
{
"id": "concept_248bx",
"type": "concept",
"name": "text",
"normalizedName": "text",
"salience": 0.6666666666666666
},
{
"id": "concept_e7b4a7",
"type": "concept",
"name": "typeof",
"normalizedName": "typeof",
"salience": 0.3333333333333333
},
{
"id": "concept_2hkf",
"type": "concept",
"name": "url",
"normalizedName": "url",
"salience": 0.6666666666666666
},
{
"id": "concept_2hzo",
"type": "concept",
"name": "vec",
"normalizedName": "vec",
"salience": 1
},
{
"id": "concept_25hr2",
"type": "concept",
"name": "vec2",
"normalizedName": "vec2",
"salience": 0.6666666666666666
},
{
"id": "concept_xr4ppa",
"type": "concept",
"name": "vecResults",
"normalizedName": "vecresults",
"salience": 1
},
{
"id": "concept_dkfr25",
"type": "concept",
"name": "vector",
"normalizedName": "vector",
"salience": 0.6666666666666666
},
{
"id": "concept_nhnbu3",
"type": "concept",
"name": "vectorSearch",
"normalizedName": "vectorsearch",
"salience": 1
}
],
"links": [
{
"id": "link_gm6aj7",
"type": "mentions",
"fromId": "frag_d3g9ts",
"fromType": "fragment",
"toId": "concept_jlea8r",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_sd7hnz",
"type": "mentions",
"fromId": "frag_d3g9ts",
"fromType": "fragment",
"toId": "concept_21lb9",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_7sfp9i",
"type": "mentions",
"fromId": "frag_d3g9ts",
"fromType": "fragment",
"toId": "concept_2hkf",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_fnz2cg",
"type": "mentions",
"fromId": "frag_d3g9ts",
"fromType": "fragment",
"toId": "concept_llkge4",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_7sekrr",
"type": "mentions",
"fromId": "frag_d3g9ts",
"fromType": "fragment",
"toId": "concept_239j",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_txdbp0",
"type": "mentions",
"fromId": "frag_d3g9ts",
"fromType": "fragment",
"toId": "concept_86zfzo",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_fmd0xz",
"type": "mentions",
"fromId": "frag_pmaij6",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_f6u12x",
"type": "mentions",
"fromId": "frag_pmaij6",
"fromType": "fragment",
"toId": "concept_rmqf14",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_78703k",
"type": "mentions",
"fromId": "frag_pmaij6",
"fromType": "fragment",
"toId": "concept_c5gn3t",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_s0gtgi",
"type": "mentions",
"fromId": "frag_pmaij6",
"fromType": "fragment",
"toId": "concept_21lb9",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_eo459i",
"type": "mentions",
"fromId": "frag_pmaij6",
"fromType": "fragment",
"toId": "concept_ql1wy8",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_fmgx0y",
"type": "mentions",
"fromId": "frag_pmaij6",
"fromType": "fragment",
"toId": "concept_1kc64o",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_zgfly7",
"type": "mentions",
"fromId": "frag_dw2gxf",
"fromType": "fragment",
"toId": "concept_243e",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_4ffy36",
"type": "mentions",
"fromId": "frag_dw2gxf",
"fromType": "fragment",
"toId": "concept_fqi63b",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_acgdcs",
"type": "mentions",
"fromId": "frag_dw2gxf",
"fromType": "fragment",
"toId": "concept_satff7",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_cz9im6",
"type": "mentions",
"fromId": "frag_dw2gxf",
"fromType": "fragment",
"toId": "concept_xsbs18",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_7s2tju",
"type": "mentions",
"fromId": "frag_dw2gxf",
"fromType": "fragment",
"toId": "concept_mu6b4o",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_xpfdbv",
"type": "mentions",
"fromId": "frag_dw2gxf",
"fromType": "fragment",
"toId": "concept_1y4mg",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_yt358s",
"type": "mentions",
"fromId": "frag_1qvdbx",
"fromType": "fragment",
"toId": "concept_1p5sz8",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_ev8u24",
"type": "mentions",
"fromId": "frag_1qvdbx",
"fromType": "fragment",
"toId": "concept_1tkpu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_9o9hc0",
"type": "mentions",
"fromId": "frag_1qvdbx",
"fromType": "fragment",
"toId": "concept_fqi63b",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_yv209n",
"type": "mentions",
"fromId": "frag_1qvdbx",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ev81a9",
"type": "mentions",
"fromId": "frag_1qvdbx",
"fromType": "fragment",
"toId": "concept_1usl5",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_yuau6w",
"type": "mentions",
"fromId": "frag_1qvdbx",
"fromType": "fragment",
"toId": "concept_1lmfpk",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_2zdydy",
"type": "mentions",
"fromId": "frag_2pvrfi",
"fromType": "fragment",
"toId": "concept_jlea8r",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_tzghbv",
"type": "mentions",
"fromId": "frag_2pvrfi",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_3q584q",
"type": "mentions",
"fromId": "frag_2pvrfi",
"fromType": "fragment",
"toId": "concept_z514xr",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_jai59l",
"type": "mentions",
"fromId": "frag_2pvrfi",
"fromType": "fragment",
"toId": "concept_1y1ii",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_2o60sz",
"type": "mentions",
"fromId": "frag_2pvrfi",
"fromType": "fragment",
"toId": "concept_kas613",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_jb746m",
"type": "mentions",
"fromId": "frag_2pvrfi",
"fromType": "fragment",
"toId": "concept_21lb9",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_l8pjy1",
"type": "mentions",
"fromId": "frag_49spg6",
"fromType": "fragment",
"toId": "concept_1jg1h8",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ol4mj7",
"type": "mentions",
"fromId": "frag_49spg6",
"fromType": "fragment",
"toId": "concept_fqi63b",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_l8hr2j",
"type": "mentions",
"fromId": "frag_49spg6",
"fromType": "fragment",
"toId": "concept_mu6b4o",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_mof359",
"type": "mentions",
"fromId": "frag_49spg6",
"fromType": "fragment",
"toId": "concept_jrfmmk",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_cl5qmg",
"type": "mentions",
"fromId": "frag_49spg6",
"fromType": "fragment",
"toId": "concept_2atg",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ld5nbu",
"type": "mentions",
"fromId": "frag_49spg6",
"fromType": "fragment",
"toId": "concept_1rhfuj",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_xcuxjb",
"type": "mentions",
"fromId": "frag_l7f27m",
"fromType": "fragment",
"toId": "concept_1sjh3j",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_iygf27",
"type": "mentions",
"fromId": "frag_l7f27m",
"fromType": "fragment",
"toId": "concept_242o",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_th2rb1",
"type": "mentions",
"fromId": "frag_l7f27m",
"fromType": "fragment",
"toId": "concept_9yqf7c",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_xdg00a",
"type": "mentions",
"fromId": "frag_l7f27m",
"fromType": "fragment",
"toId": "concept_1rhfuj",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_xheqtu",
"type": "mentions",
"fromId": "frag_l7f27m",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_8am7w7",
"type": "mentions",
"fromId": "frag_l7f27m",
"fromType": "fragment",
"toId": "concept_fqi63b",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_gz4d4d",
"type": "mentions",
"fromId": "frag_4yjic",
"fromType": "fragment",
"toId": "concept_1lj7f1",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_syfnx7",
"type": "mentions",
"fromId": "frag_4yjic",
"fromType": "fragment",
"toId": "concept_flreew",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_gyg352",
"type": "mentions",
"fromId": "frag_4yjic",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_gyjxt9",
"type": "mentions",
"fromId": "frag_4yjic",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_awlf12",
"type": "mentions",
"fromId": "frag_4yjic",
"fromType": "fragment",
"toId": "concept_1u3dy",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_h0dn34",
"type": "mentions",
"fromId": "frag_4yjic",
"fromType": "fragment",
"toId": "concept_1nqriq",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_pqdkx3",
"type": "mentions",
"fromId": "frag_qhu1w0",
"fromType": "fragment",
"toId": "concept_jl0fx8",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 6
},
{
"id": "link_ibhsjx",
"type": "mentions",
"fromId": "frag_qhu1w0",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_smqahn",
"type": "mentions",
"fromId": "frag_qhu1w0",
"fromType": "fragment",
"toId": "concept_dh6z62",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_ibln84",
"type": "mentions",
"fromId": "frag_qhu1w0",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_rkj8nd",
"type": "mentions",
"fromId": "frag_qhu1w0",
"fromType": "fragment",
"toId": "concept_h0dhpz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_lfahv",
"type": "mentions",
"fromId": "frag_qhu1w0",
"fromType": "fragment",
"toId": "concept_1u3dy",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_4qw4lr",
"type": "mentions",
"fromId": "frag_3vvxiw",
"fromType": "fragment",
"toId": "concept_1j7mqk",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_4pldf0",
"type": "mentions",
"fromId": "frag_3vvxiw",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_2hfic9",
"type": "mentions",
"fromId": "frag_3vvxiw",
"fromType": "fragment",
"toId": "concept_6azi1v",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_4phiqt",
"type": "mentions",
"fromId": "frag_3vvxiw",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_6q54os",
"type": "mentions",
"fromId": "frag_3vvxiw",
"fromType": "fragment",
"toId": "concept_1u3dy",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_j4egvm",
"type": "mentions",
"fromId": "frag_3vvxiw",
"fromType": "fragment",
"toId": "concept_e91o2j",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_wrcf7r",
"type": "mentions",
"fromId": "frag_o5huex",
"fromType": "fragment",
"toId": "concept_6c0biv",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_amhcre",
"type": "mentions",
"fromId": "frag_o5huex",
"fromType": "fragment",
"toId": "concept_pmrgxq",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_un2yk1",
"type": "mentions",
"fromId": "frag_o5huex",
"fromType": "fragment",
"toId": "concept_1s9m88",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_bqls5h",
"type": "mentions",
"fromId": "frag_o5huex",
"fromType": "fragment",
"toId": "concept_nd5e82",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_ujebtz",
"type": "mentions",
"fromId": "frag_o5huex",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_uji6i6",
"type": "mentions",
"fromId": "frag_o5huex",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_1n7sjb",
"type": "mentions",
"fromId": "frag_yice37",
"fromType": "fragment",
"toId": "concept_6c0biv",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_h1o4f",
"type": "mentions",
"fromId": "frag_yice37",
"fromType": "fragment",
"toId": "concept_1s9m88",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_kqauh",
"type": "mentions",
"fromId": "frag_yice37",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_kmg6a",
"type": "mentions",
"fromId": "frag_yice37",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ydtwdj",
"type": "mentions",
"fromId": "frag_yice37",
"fromType": "fragment",
"toId": "concept_1u3dy",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_k20v6",
"type": "mentions",
"fromId": "frag_yice37",
"fromType": "fragment",
"toId": "concept_1lj7f1",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_4ct6x6",
"type": "mentions",
"fromId": "frag_qso8rl",
"fromType": "fragment",
"toId": "concept_1pb54r",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_6i10h2",
"type": "mentions",
"fromId": "frag_qso8rl",
"fromType": "fragment",
"toId": "concept_6c0biv",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_4drjtc",
"type": "mentions",
"fromId": "frag_qso8rl",
"fromType": "fragment",
"toId": "concept_1s9m88",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_4a2x3a",
"type": "mentions",
"fromId": "frag_qso8rl",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_4a6rrh",
"type": "mentions",
"fromId": "frag_qso8rl",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_dlsxva",
"type": "mentions",
"fromId": "frag_qso8rl",
"fromType": "fragment",
"toId": "concept_1u3dy",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_h9ddvu",
"type": "mentions",
"fromId": "frag_83cldn",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_aowd8u",
"type": "mentions",
"fromId": "frag_83cldn",
"fromType": "fragment",
"toId": "concept_llkge4",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_h9x9la",
"type": "mentions",
"fromId": "frag_83cldn",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_ivq2h9",
"type": "mentions",
"fromId": "frag_83cldn",
"fromType": "fragment",
"toId": "concept_1wtnv",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_9qp523",
"type": "mentions",
"fromId": "frag_83cldn",
"fromType": "fragment",
"toId": "concept_jlea8r",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_ivq6be",
"type": "mentions",
"fromId": "frag_83cldn",
"fromType": "fragment",
"toId": "concept_1y1ii",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_l1qqlg",
"type": "mentions",
"fromId": "frag_ou8eku",
"fromType": "fragment",
"toId": "concept_f09bfe",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_34xha7",
"type": "mentions",
"fromId": "frag_ou8eku",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_rl8v8h",
"type": "mentions",
"fromId": "frag_ou8eku",
"fromType": "fragment",
"toId": "concept_1xdsg",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_j6mvp3",
"type": "mentions",
"fromId": "frag_ou8eku",
"fromType": "fragment",
"toId": "concept_b0w3to",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_34hg8y",
"type": "mentions",
"fromId": "frag_ou8eku",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_34eykp",
"type": "mentions",
"fromId": "frag_ou8eku",
"fromType": "fragment",
"toId": "concept_1keqml",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_uiayqy",
"type": "mentions",
"fromId": "frag_mjx4px",
"fromType": "fragment",
"toId": "concept_2487m",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_3rvm3z",
"type": "mentions",
"fromId": "frag_mjx4px",
"fromType": "fragment",
"toId": "concept_gvydlk",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_lzgqhn",
"type": "mentions",
"fromId": "frag_mjx4px",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_lywus7",
"type": "mentions",
"fromId": "frag_mjx4px",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_ah0cc5",
"type": "mentions",
"fromId": "frag_mjx4px",
"fromType": "fragment",
"toId": "concept_24vs",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_k84ty6",
"type": "mentions",
"fromId": "frag_mjx4px",
"fromType": "fragment",
"toId": "concept_5amdjs",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_cty494",
"type": "mentions",
"fromId": "frag_xgccse",
"fromType": "fragment",
"toId": "concept_1u7gb",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_bee0fu",
"type": "mentions",
"fromId": "frag_xgccse",
"fromType": "fragment",
"toId": "concept_u4un1i",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_s93sit",
"type": "mentions",
"fromId": "frag_xgccse",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_s9jtk2",
"type": "mentions",
"fromId": "frag_xgccse",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_s9no89",
"type": "mentions",
"fromId": "frag_xgccse",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ilc1ko",
"type": "mentions",
"fromId": "frag_xgccse",
"fromType": "fragment",
"toId": "concept_f09bfe",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_i3umg2",
"type": "mentions",
"fromId": "frag_4t3zpb",
"fromType": "fragment",
"toId": "concept_2487m",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_i377kg",
"type": "mentions",
"fromId": "frag_4t3zpb",
"fromType": "fragment",
"toId": "concept_1vjle",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_x32n50",
"type": "mentions",
"fromId": "frag_4t3zpb",
"fromType": "fragment",
"toId": "concept_raj06o",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_7b1l0d",
"type": "mentions",
"fromId": "frag_4t3zpb",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_7blgpt",
"type": "mentions",
"fromId": "frag_4t3zpb",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_z8ql2n",
"type": "mentions",
"fromId": "frag_4t3zpb",
"fromType": "fragment",
"toId": "concept_o1b6i6",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_k51fms",
"type": "mentions",
"fromId": "frag_g3bxss",
"fromType": "fragment",
"toId": "concept_1m5vi9",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_i7m6y9",
"type": "mentions",
"fromId": "frag_g3bxss",
"fromType": "fragment",
"toId": "concept_vlhikl",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_k49vja",
"type": "mentions",
"fromId": "frag_g3bxss",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_k4pwkj",
"type": "mentions",
"fromId": "frag_g3bxss",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_k4tr8q",
"type": "mentions",
"fromId": "frag_g3bxss",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_lvls2r",
"type": "mentions",
"fromId": "frag_g3bxss",
"fromType": "fragment",
"toId": "concept_5amdjs",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_uf3de5",
"type": "mentions",
"fromId": "frag_g30jd6",
"fromType": "fragment",
"toId": "concept_iamaui",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 5
},
{
"id": "link_7v1imq",
"type": "mentions",
"fromId": "frag_g30jd6",
"fromType": "fragment",
"toId": "concept_9n44xh",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_41f6bg",
"type": "mentions",
"fromId": "frag_g30jd6",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_41z20w",
"type": "mentions",
"fromId": "frag_g30jd6",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_s83e73",
"type": "mentions",
"fromId": "frag_g30jd6",
"fromType": "fragment",
"toId": "concept_f09bfe",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_63r7js",
"type": "mentions",
"fromId": "frag_g30jd6",
"fromType": "fragment",
"toId": "concept_77o2i1",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_6gdclb",
"type": "mentions",
"fromId": "frag_h3k825",
"fromType": "fragment",
"toId": "concept_1pb5x5",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_6d705b",
"type": "mentions",
"fromId": "frag_h3k825",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_6dqvur",
"type": "mentions",
"fromId": "frag_h3k825",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_yuiw1f",
"type": "mentions",
"fromId": "frag_h3k825",
"fromType": "fragment",
"toId": "concept_o1b6i6",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_vbxzaf",
"type": "mentions",
"fromId": "frag_h3k825",
"fromType": "fragment",
"toId": "concept_wvtd0j",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_ujv80y",
"type": "mentions",
"fromId": "frag_h3k825",
"fromType": "fragment",
"toId": "concept_f09bfe",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_u202ao",
"type": "mentions",
"fromId": "frag_ivfanb",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 6
},
{
"id": "link_5vbuf1",
"type": "mentions",
"fromId": "frag_ivfanb",
"fromType": "fragment",
"toId": "concept_f09bfe",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 6
},
{
"id": "link_4bxc0w",
"type": "mentions",
"fromId": "frag_ivfanb",
"fromType": "fragment",
"toId": "concept_gv37qm",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_ly48ir",
"type": "mentions",
"fromId": "frag_ivfanb",
"fromType": "fragment",
"toId": "concept_1t24y",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_7nfh0u",
"type": "mentions",
"fromId": "frag_ivfanb",
"fromType": "fragment",
"toId": "concept_b6ebeu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_u1k19f",
"type": "mentions",
"fromId": "frag_ivfanb",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_1ikequ",
"type": "mentions",
"fromId": "frag_g1qshr",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_1i1w1c",
"type": "mentions",
"fromId": "frag_g1qshr",
"fromType": "fragment",
"toId": "concept_1keqml",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_mo62l4",
"type": "mentions",
"fromId": "frag_g1qshr",
"fromType": "fragment",
"toId": "concept_dlzsam",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_mo3t4t",
"type": "mentions",
"fromId": "frag_g1qshr",
"fromType": "fragment",
"toId": "concept_f09bfe",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_1i4dpl",
"type": "mentions",
"fromId": "frag_g1qshr",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_1i0j1e",
"type": "mentions",
"fromId": "frag_g1qshr",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_yh3xba",
"type": "mentions",
"fromId": "frag_m2z8gs",
"fromType": "fragment",
"toId": "concept_7wml4k",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_xixeog",
"type": "mentions",
"fromId": "frag_m2z8gs",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_xizth4",
"type": "mentions",
"fromId": "frag_m2z8gs",
"fromType": "fragment",
"toId": "concept_1keo3c",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_8yr8wd",
"type": "mentions",
"fromId": "frag_m2z8gs",
"fromType": "fragment",
"toId": "concept_mu6b4o",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ycjik0",
"type": "mentions",
"fromId": "frag_m2z8gs",
"fromType": "fragment",
"toId": "concept_8b8yt5",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_dogahu",
"type": "mentions",
"fromId": "frag_m2z8gs",
"fromType": "fragment",
"toId": "concept_e7b4a7",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_texq5s",
"type": "mentions",
"fromId": "frag_la8iqs",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 6
},
{
"id": "link_tfhlv8",
"type": "mentions",
"fromId": "frag_la8iqs",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_qk2x0k",
"type": "mentions",
"fromId": "frag_la8iqs",
"fromType": "fragment",
"toId": "concept_7liuz8",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_uq9yjr",
"type": "mentions",
"fromId": "frag_la8iqs",
"fromType": "fragment",
"toId": "concept_1wtnv",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_2ev77v",
"type": "mentions",
"fromId": "frag_la8iqs",
"fromType": "fragment",
"toId": "concept_jlea8r",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_uqa2dw",
"type": "mentions",
"fromId": "frag_la8iqs",
"fromType": "fragment",
"toId": "concept_1y1ii",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_nyigeg",
"type": "mentions",
"fromId": "frag_k2wcc",
"fromType": "fragment",
"toId": "concept_1zybu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_6zabo1",
"type": "mentions",
"fromId": "frag_k2wcc",
"fromType": "fragment",
"toId": "concept_i3xxga",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_vu9w1p",
"type": "mentions",
"fromId": "frag_k2wcc",
"fromType": "fragment",
"toId": "concept_3bbkvv",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_wnffzd",
"type": "mentions",
"fromId": "frag_k2wcc",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_nzaezu",
"type": "mentions",
"fromId": "frag_k2wcc",
"fromType": "fragment",
"toId": "concept_20b63",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_wnblb6",
"type": "mentions",
"fromId": "frag_k2wcc",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_x38ek6",
"type": "mentions",
"fromId": "frag_amqxax",
"fromType": "fragment",
"toId": "concept_2hzo",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_m39vrf",
"type": "mentions",
"fromId": "frag_amqxax",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_m3yuge",
"type": "mentions",
"fromId": "frag_amqxax",
"fromType": "fragment",
"toId": "concept_1liwnt",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_mwffp3",
"type": "mentions",
"fromId": "frag_amqxax",
"fromType": "fragment",
"toId": "concept_3bbkvv",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_osgc5n",
"type": "mentions",
"fromId": "frag_amqxax",
"fromType": "fragment",
"toId": "concept_dkfr25",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_x39j57",
"type": "mentions",
"fromId": "frag_amqxax",
"fromType": "fragment",
"toId": "concept_22cy",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_rktrqw",
"type": "mentions",
"fromId": "frag_k3j5ot",
"fromType": "fragment",
"toId": "concept_25hr2",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_2wd36k",
"type": "mentions",
"fromId": "frag_k3j5ot",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_vcpp15",
"type": "mentions",
"fromId": "frag_k3j5ot",
"fromType": "fragment",
"toId": "concept_mmi027",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_2wt47t",
"type": "mentions",
"fromId": "frag_k3j5ot",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_2wwyw0",
"type": "mentions",
"fromId": "frag_k3j5ot",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_2xi2ws",
"type": "mentions",
"fromId": "frag_k3j5ot",
"fromType": "fragment",
"toId": "concept_1liwnt",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_w1zyo6",
"type": "mentions",
"fromId": "frag_oxk0al",
"fromType": "fragment",
"toId": "concept_ljky0p",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_uk9tet",
"type": "mentions",
"fromId": "frag_oxk0al",
"fromType": "fragment",
"toId": "concept_q1bzdy",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_y0xfwi",
"type": "mentions",
"fromId": "frag_oxk0al",
"fromType": "fragment",
"toId": "concept_248bx",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_b1dpj1",
"type": "mentions",
"fromId": "frag_oxk0al",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_b1tqka",
"type": "mentions",
"fromId": "frag_oxk0al",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_b1xl8h",
"type": "mentions",
"fromId": "frag_oxk0al",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_mpfcly",
"type": "mentions",
"fromId": "frag_w7ug9l",
"fromType": "fragment",
"toId": "concept_1hqksr",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_gs8fg9",
"type": "mentions",
"fromId": "frag_w7ug9l",
"fromType": "fragment",
"toId": "concept_20b63",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_2zc842",
"type": "mentions",
"fromId": "frag_w7ug9l",
"fromType": "fragment",
"toId": "concept_gpo83y",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_137lb5",
"type": "mentions",
"fromId": "frag_w7ug9l",
"fromType": "fragment",
"toId": "concept_cpps5l",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_mo201m",
"type": "mentions",
"fromId": "frag_w7ug9l",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_30349q",
"type": "mentions",
"fromId": "frag_w7ug9l",
"fromType": "fragment",
"toId": "concept_i3xxga",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_e926ua",
"type": "mentions",
"fromId": "frag_h1uzlu",
"fromType": "fragment",
"toId": "concept_w9ow8",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_grp8co",
"type": "mentions",
"fromId": "frag_h1uzlu",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_grmtk0",
"type": "mentions",
"fromId": "frag_h1uzlu",
"fromType": "fragment",
"toId": "concept_1keo3c",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_brq61n",
"type": "mentions",
"fromId": "frag_h1uzlu",
"fromType": "fragment",
"toId": "concept_mu6b4o",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_7214g6",
"type": "mentions",
"fromId": "frag_h1uzlu",
"fromType": "fragment",
"toId": "concept_e7b4a7",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_qezy30",
"type": "mentions",
"fromId": "frag_rrcn8h",
"fromType": "fragment",
"toId": "concept_gvydl6",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_ivbn1f",
"type": "mentions",
"fromId": "frag_rrcn8h",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_iz6j6s",
"type": "mentions",
"fromId": "frag_rrcn8h",
"fromType": "fragment",
"toId": "concept_1rhfuj",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_smf4sp",
"type": "mentions",
"fromId": "frag_rrcn8h",
"fromType": "fragment",
"toId": "concept_cbkjnw",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_iurrbz",
"type": "mentions",
"fromId": "frag_rrcn8h",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_qz3qo9",
"type": "mentions",
"fromId": "frag_rrcn8h",
"fromType": "fragment",
"toId": "concept_fqi63b",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_er113i",
"type": "mentions",
"fromId": "frag_z6ich8",
"fromType": "fragment",
"toId": "concept_hkekis",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_67yaa7",
"type": "mentions",
"fromId": "frag_z6ich8",
"fromType": "fragment",
"toId": "concept_22cy",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_baijq1",
"type": "mentions",
"fromId": "frag_z6ich8",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_k9jq2f",
"type": "mentions",
"fromId": "frag_z6ich8",
"fromType": "fragment",
"toId": "concept_1zoco",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_bd77ix",
"type": "mentions",
"fromId": "frag_z6ich8",
"fromType": "fragment",
"toId": "concept_1fehth",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ewaxlk",
"type": "mentions",
"fromId": "frag_z6ich8",
"fromType": "fragment",
"toId": "concept_j6wxa1",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_9z80vh",
"type": "mentions",
"fromId": "frag_5i1o5v",
"fromType": "fragment",
"toId": "concept_pmrgxq",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_e711pf",
"type": "mentions",
"fromId": "frag_5i1o5v",
"fromType": "fragment",
"toId": "concept_gr1mao",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_465x9j",
"type": "mentions",
"fromId": "frag_5i1o5v",
"fromType": "fragment",
"toId": "concept_24vs",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_frkfbo",
"type": "mentions",
"fromId": "frag_5i1o5v",
"fromType": "fragment",
"toId": "concept_dh6z62",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_9k31og",
"type": "mentions",
"fromId": "frag_5i1o5v",
"fromType": "fragment",
"toId": "concept_qjcwf1",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_v8l7ny",
"type": "mentions",
"fromId": "frag_5i1o5v",
"fromType": "fragment",
"toId": "concept_1nqriq",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_wh3yx2",
"type": "mentions",
"fromId": "frag_1wv3le",
"fromType": "fragment",
"toId": "concept_ofkd9r",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_99sgv1",
"type": "mentions",
"fromId": "frag_1wv3le",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_9al3lt",
"type": "mentions",
"fromId": "frag_1wv3le",
"fromType": "fragment",
"toId": "concept_1loq3f",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_xd9957",
"type": "mentions",
"fromId": "frag_1wv3le",
"fromType": "fragment",
"toId": "concept_dgvlef",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_vw37qc",
"type": "mentions",
"fromId": "frag_1wv3le",
"fromType": "fragment",
"toId": "concept_pmrgxq",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_9dw0bj",
"type": "mentions",
"fromId": "frag_1wv3le",
"fromType": "fragment",
"toId": "concept_1t1x1u",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_nj3qtq",
"type": "mentions",
"fromId": "frag_jx95go",
"fromType": "fragment",
"toId": "concept_xr4ppa",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_io6bw3",
"type": "mentions",
"fromId": "frag_jx95go",
"fromType": "fragment",
"toId": "concept_nhnbu3",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_a655yl",
"type": "mentions",
"fromId": "frag_jx95go",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_elexuy",
"type": "mentions",
"fromId": "frag_jx95go",
"fromType": "fragment",
"toId": "concept_etr8bp",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_dz8o3h",
"type": "mentions",
"fromId": "frag_jx95go",
"fromType": "fragment",
"toId": "concept_dkfr25",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_a6n0x0",
"type": "mentions",
"fromId": "frag_jx95go",
"fromType": "fragment",
"toId": "concept_1jf909",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ae3a56",
"type": "mentions",
"fromId": "frag_mb8wqq",
"fromType": "fragment",
"toId": "concept_htl1p0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_c4qnhz",
"type": "mentions",
"fromId": "frag_mb8wqq",
"fromType": "fragment",
"toId": "concept_ljhtmq",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_fshq17",
"type": "mentions",
"fromId": "frag_mb8wqq",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_akrhoa",
"type": "mentions",
"fromId": "frag_mb8wqq",
"fromType": "fragment",
"toId": "concept_iap7oa",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_fszkzm",
"type": "mentions",
"fromId": "frag_mb8wqq",
"fromType": "fragment",
"toId": "concept_1jf909",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_foe6kp",
"type": "mentions",
"fromId": "frag_mb8wqq",
"fromType": "fragment",
"toId": "concept_1t1x1u",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_m0kasq",
"type": "mentions",
"fromId": "frag_c2gwcq",
"fromType": "fragment",
"toId": "concept_ihtre0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_3ejrm9",
"type": "mentions",
"fromId": "frag_c2gwcq",
"fromType": "fragment",
"toId": "concept_54l41w",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_4h39in",
"type": "mentions",
"fromId": "frag_c2gwcq",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_4gn8he",
"type": "mentions",
"fromId": "frag_c2gwcq",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_4gjdt7",
"type": "mentions",
"fromId": "frag_c2gwcq",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_28hden",
"type": "mentions",
"fromId": "frag_c2gwcq",
"fromType": "fragment",
"toId": "concept_6azi1v",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_mn7g18",
"type": "mentions",
"fromId": "frag_lybfjt",
"fromType": "fragment",
"toId": "concept_jw39c4",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 6
},
{
"id": "link_maooot",
"type": "mentions",
"fromId": "frag_lybfjt",
"fromType": "fragment",
"toId": "concept_54l41w",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_p0yqbx",
"type": "mentions",
"fromId": "frag_lybfjt",
"fromType": "fragment",
"toId": "concept_etr8bp",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_l8l7to",
"type": "mentions",
"fromId": "frag_lybfjt",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_pn503e",
"type": "mentions",
"fromId": "frag_lybfjt",
"fromType": "fragment",
"toId": "concept_dkfr25",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_l856sf",
"type": "mentions",
"fromId": "frag_lybfjt",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ylws04",
"type": "mentions",
"fromId": "frag_4nve2i",
"fromType": "fragment",
"toId": "concept_fqi63b",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_ejrf1l",
"type": "mentions",
"fromId": "frag_4nve2i",
"fromType": "fragment",
"toId": "concept_2atg",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_cab5of",
"type": "mentions",
"fromId": "frag_4nve2i",
"fromType": "fragment",
"toId": "concept_54a26p",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_yrio8h",
"type": "mentions",
"fromId": "frag_4nve2i",
"fromType": "fragment",
"toId": "concept_fgc06b",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_f7q17i",
"type": "mentions",
"fromId": "frag_4nve2i",
"fromType": "fragment",
"toId": "concept_9ww6vb",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ou4ny5",
"type": "mentions",
"fromId": "frag_4nve2i",
"fromType": "fragment",
"toId": "concept_1usl5",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_u09vol",
"type": "mentions",
"fromId": "frag_h74lv",
"fromType": "fragment",
"toId": "concept_25ph",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_uuc6f",
"type": "mentions",
"fromId": "frag_h74lv",
"fromType": "fragment",
"toId": "concept_1lmfpk",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_wd2xa",
"type": "mentions",
"fromId": "frag_h74lv",
"fromType": "fragment",
"toId": "concept_1k80xn",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ob70oh",
"type": "mentions",
"fromId": "frag_h74lv",
"fromType": "fragment",
"toId": "concept_fqi63b",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_6ulya2",
"type": "mentions",
"fromId": "frag_h74lv",
"fromType": "fragment",
"toId": "concept_1v19a",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_6awdw",
"type": "mentions",
"fromId": "frag_h74lv",
"fromType": "fragment",
"toId": "concept_54a26p",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_5fyk8w",
"type": "adjacent",
"fromId": "frag_d3g9ts",
"fromType": "fragment",
"toId": "frag_pmaij6",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_hn8tmc",
"type": "adjacent",
"fromId": "frag_pmaij6",
"fromType": "fragment",
"toId": "frag_dw2gxf",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_ppo0qr",
"type": "adjacent",
"fromId": "frag_dw2gxf",
"fromType": "fragment",
"toId": "frag_1qvdbx",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_hnzag",
"type": "adjacent",
"fromId": "frag_1qvdbx",
"fromType": "fragment",
"toId": "frag_2pvrfi",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_w05mpp",
"type": "adjacent",
"fromId": "frag_2pvrfi",
"fromType": "fragment",
"toId": "frag_49spg6",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_3zquph",
"type": "adjacent",
"fromId": "frag_49spg6",
"fromType": "fragment",
"toId": "frag_l7f27m",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_tt6jkz",
"type": "adjacent",
"fromId": "frag_l7f27m",
"fromType": "fragment",
"toId": "frag_4yjic",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_lu2bze",
"type": "adjacent",
"fromId": "frag_4yjic",
"fromType": "fragment",
"toId": "frag_qhu1w0",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_83dkye",
"type": "adjacent",
"fromId": "frag_qhu1w0",
"fromType": "fragment",
"toId": "frag_3vvxiw",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_34uo8y",
"type": "adjacent",
"fromId": "frag_3vvxiw",
"fromType": "fragment",
"toId": "frag_o5huex",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_bi2hj5",
"type": "adjacent",
"fromId": "frag_o5huex",
"fromType": "fragment",
"toId": "frag_yice37",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_cizpxa",
"type": "adjacent",
"fromId": "frag_yice37",
"fromType": "fragment",
"toId": "frag_qso8rl",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_2reo2g",
"type": "adjacent",
"fromId": "frag_qso8rl",
"fromType": "fragment",
"toId": "frag_83cldn",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_qggyam",
"type": "adjacent",
"fromId": "frag_83cldn",
"fromType": "fragment",
"toId": "frag_ou8eku",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_9rnzir",
"type": "adjacent",
"fromId": "frag_ou8eku",
"fromType": "fragment",
"toId": "frag_mjx4px",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_w0ls9d",
"type": "adjacent",
"fromId": "frag_mjx4px",
"fromType": "fragment",
"toId": "frag_xgccse",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_k1gx09",
"type": "adjacent",
"fromId": "frag_xgccse",
"fromType": "fragment",
"toId": "frag_4t3zpb",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_bi4qq0",
"type": "adjacent",
"fromId": "frag_4t3zpb",
"fromType": "fragment",
"toId": "frag_g3bxss",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_303yz",
"type": "adjacent",
"fromId": "frag_g3bxss",
"fromType": "fragment",
"toId": "frag_g30jd6",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_c10pda",
"type": "adjacent",
"fromId": "frag_g30jd6",
"fromType": "fragment",
"toId": "frag_h3k825",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_havre8",
"type": "adjacent",
"fromId": "frag_h3k825",
"fromType": "fragment",
"toId": "frag_ivfanb",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_kqof2h",
"type": "adjacent",
"fromId": "frag_ivfanb",
"fromType": "fragment",
"toId": "frag_g1qshr",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_5qkyvc",
"type": "adjacent",
"fromId": "frag_g1qshr",
"fromType": "fragment",
"toId": "frag_m2z8gs",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_3pley0",
"type": "adjacent",
"fromId": "frag_m2z8gs",
"fromType": "fragment",
"toId": "frag_la8iqs",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_porisp",
"type": "adjacent",
"fromId": "frag_la8iqs",
"fromType": "fragment",
"toId": "frag_k2wcc",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_ehpuil",
"type": "adjacent",
"fromId": "frag_k2wcc",
"fromType": "fragment",
"toId": "frag_amqxax",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_v7bo83",
"type": "adjacent",
"fromId": "frag_amqxax",
"fromType": "fragment",
"toId": "frag_k3j5ot",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_hc4hkg",
"type": "adjacent",
"fromId": "frag_k3j5ot",
"fromType": "fragment",
"toId": "frag_oxk0al",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_tj8mbf",
"type": "adjacent",
"fromId": "frag_oxk0al",
"fromType": "fragment",
"toId": "frag_w7ug9l",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_sprb4x",
"type": "adjacent",
"fromId": "frag_w7ug9l",
"fromType": "fragment",
"toId": "frag_h1uzlu",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_t55ser",
"type": "adjacent",
"fromId": "frag_h1uzlu",
"fromType": "fragment",
"toId": "frag_rrcn8h",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_udj8qi",
"type": "adjacent",
"fromId": "frag_rrcn8h",
"fromType": "fragment",
"toId": "frag_z6ich8",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_26cgxo",
"type": "adjacent",
"fromId": "frag_z6ich8",
"fromType": "fragment",
"toId": "frag_5i1o5v",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_wxy4ag",
"type": "adjacent",
"fromId": "frag_5i1o5v",
"fromType": "fragment",
"toId": "frag_1wv3le",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_va27i3",
"type": "adjacent",
"fromId": "frag_1wv3le",
"fromType": "fragment",
"toId": "frag_jx95go",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_i40hvz",
"type": "adjacent",
"fromId": "frag_jx95go",
"fromType": "fragment",
"toId": "frag_mb8wqq",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_lb7pns",
"type": "adjacent",
"fromId": "frag_mb8wqq",
"fromType": "fragment",
"toId": "frag_c2gwcq",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_qzbzwd",
"type": "adjacent",
"fromId": "frag_c2gwcq",
"fromType": "fragment",
"toId": "frag_lybfjt",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_lep7w4",
"type": "adjacent",
"fromId": "frag_lybfjt",
"fromType": "fragment",
"toId": "frag_4nve2i",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_fg0u0w",
"type": "adjacent",
"fromId": "frag_4nve2i",
"fromType": "fragment",
"toId": "frag_h74lv",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
}
]
}
FILE:dist/embedding-providers/EmbeddingProvider.js
/**
* EmbeddingProvider Interface
* 所有 embedding provider 必须实现此接口契约。
*/
class EmbeddingProvider {
/**
* 将文本列表转为向量
* @param {string[]} texts
* @returns {Promise<number[][]>}
*/
async embed(texts) {
throw new Error('Not implemented');
}
/**
* 返回 provider 名称
*/
get name() {
return 'unknown';
}
/**
* 返回向量维度
*/
get dimension() {
throw new Error('Not implemented');
}
}
module.exports = { EmbeddingProvider };
FILE:dist/embedding-providers/MockProvider.js
/**
* MockProvider
* 返回随机向量,用于测试和离线开发。
*/
const { EmbeddingProvider } = require('./EmbeddingProvider');
const DEFAULT_DIM = 1536;
class MockProvider extends EmbeddingProvider {
constructor({ dimension = DEFAULT_DIM, seed = 42 } = {}) {
super();
this._dim = dimension;
this._seed = seed;
this._cache = new Map();
}
get name() {
return 'mock';
}
get dimension() {
return this._dim;
}
_pseudoRandom(text) {
let hash = 0;
for (let i = 0; i < text.length; i += 1) {
hash = ((hash << 5) - hash + text.charCodeAt(i)) | 0;
}
return Math.abs(hash) / 0x7fffffff;
}
async embed(texts) {
return texts.map((text) => {
if (this._cache.has(text)) return this._cache.get(text);
const vec = [];
for (let i = 0; i < this._dim; i += 1) {
// deterministic random based on text + index
const base = this._pseudoRandom(text + i);
vec.push(base);
}
// L2 normalize
const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0));
const normalized = norm > 0 ? vec.map((v) => v / norm) : vec;
this._cache.set(text, normalized);
return normalized;
});
}
}
module.exports = { MockProvider };
FILE:dist/embedding-providers/OpenAICompatibleProvider.js
/**
* OpenAICompatibleProvider
* 调用 OpenAI-compatible API endpoint(如 vLLM、Ollama、Azure OpenAI 等)。
*/
const { EmbeddingProvider } = require('./EmbeddingProvider');
const DEFAULT_DIM = 1536;
class OpenAICompatibleProvider extends EmbeddingProvider {
constructor({ baseURL = 'https://api.openai.com/v1', apiKey = '', model = 'text-embedding-3-small', dimension = DEFAULT_DIM, batchSize = 100 } = {}) {
super();
this._baseURL = baseURL.replace(/\/$/, '');
this._apiKey = apiKey;
this._model = model;
this._dim = dimension;
this._batchSize = batchSize;
}
get name() {
return `openai-compatible:this._model`;
}
get dimension() {
return this._dim;
}
async embed(texts) {
if (!texts || texts.length === 0) return [];
const results = [];
for (let i = 0; i < texts.length; i += this._batchSize) {
const batch = texts.slice(i, i + this._batchSize);
const vectors = await this._fetchBatch(batch);
results.push(...vectors);
}
return results;
}
async _fetchBatch(batch) {
const url = `this._baseURL/embeddings`;
const body = {
model: this._model,
input: batch
};
const headers = {
'Content-Type': 'application/json'
};
if (this._apiKey) {
headers['Authorization'] = `Bearer this._apiKey`;
}
const resp = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body)
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
throw new Error(`Embedding API error resp.status: text`);
}
const json = await resp.json();
if (!json.data || !Array.isArray(json.data)) {
throw new Error(`Unexpected embedding response format`);
}
// sort by index to maintain order
const sorted = json.data.slice().sort((a, b) => a.index - b.index);
return sorted.map((item) => {
if (!item.embedding || !Array.isArray(item.embedding)) {
throw new Error(`Invalid embedding vector in response`);
}
return item.embedding;
});
}
}
module.exports = { OpenAICompatibleProvider };
FILE:dist/embedding-providers/index.js
/**
* embedding-providers/index.js
* Factory: 根据配置返回对应的 embedding provider 实例。
*/
const { MockProvider } = require('./MockProvider');
const { OpenAICompatibleProvider } = require('./OpenAICompatibleProvider');
function createEmbeddingProvider(type = 'mock', options = {}) {
switch (type) {
case 'mock':
return new MockProvider(options);
case 'openai':
return new OpenAICompatibleProvider(options);
default:
throw new Error(`Unknown embedding provider type: type. Use 'mock' or 'openai'.`);
}
}
module.exports = { createEmbeddingProvider, MockProvider, OpenAICompatibleProvider };
FILE:dist/index.js
#!/usr/bin/env node
/**
* LinkMind CLI - 知识连接引擎
* 支持 adapter 切换(json/sqlite)和 embedding 切换(keyword/openai)
*/
const fs = require('fs');
const path = require('path');
const THIS_DIR = __dirname.includes(`path.sepdist`) ? __dirname : __dirname;
const IS_DIST = THIS_DIR.includes(`path.sepdist`);
const ROOT = IS_DIST ? path.resolve(THIS_DIR, '..') : path.resolve(THIS_DIR, '..');
const DATA_DIR = path.join(ROOT, 'data');
function createEmptyDb() {
return {
version: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
documents: [],
fragments: [],
concepts: [],
links: []
};
}
function stableId(prefix, input) {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
}
return `prefix_Math.abs(hash).toString(36)`;
}
function normalizeConcept(text) {
return text
.toLowerCase()
.replace(/[\u2018\u2019'"""''()()【】\[\],.:;!?/\\]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
const STOPWORDS = new Set([
'the','and','for','that','this','with','from','into','your','have','will','not','are','was','were',
'can','could','should','about','what','when','where','which','while','than','then','them','they',
'their','there','here','also','more','most','some','such','using','used','use','make','made',
'over','under','very','just','only','each','been','being','does','did','done','how','why',
'our','you','its','his','her','she','him','who','has','had','but','too','via','per',
'one','two','three',
'我们','你们','他们','这个','那个','一个','一种','可以','需要','如果','因为','所以','以及',
'进行','通过','没有','不是','就是','如何','什么','为什么','时候','今天','已经','还有',
'对于','一些','这种','那些','并且','或者','主要','用户','系统','功能','能力','作为'
]);
function extractConcepts(text) {
const zhMatches = text.match(/[\u4e00-\u9fa5]{2,8}/g) || [];
const enMatches = text.match(/[A-Za-z][A-Za-z0-9_-]{2,}/g) || [];
const tokens = [...zhMatches, ...enMatches]
.map((item) => item.trim())
.map((item) => ({ raw: item, normalized: normalizeConcept(item) }))
.filter((item) => item.normalized && !STOPWORDS.has(item.normalized));
const counts = new Map();
for (const token of tokens) {
counts.set(token.normalized, (counts.get(token.normalized) || 0) + 1);
}
return [...counts.entries()]
.map(([normalizedName, count]) => ({
normalizedName,
name: tokens.find((item) => item.normalized === normalizedName)?.raw || normalizedName,
count
}))
.sort((a, b) => b.count - a.count || a.normalizedName.localeCompare(b.normalizedName))
.slice(0, 12);
}
function splitParagraphs(text) {
return text
.split(/\n\s*\n+/)
.map((part) => part.trim())
.filter(Boolean);
}
function upsertDocument(db, doc) {
const existingIndex = db.documents.findIndex((item) => item.id === doc.id);
if (existingIndex >= 0) db.documents[existingIndex] = doc;
else db.documents.push(doc);
}
function buildForDocument(db, document) {
const fragmentIds = db.fragments.filter((f) => f.documentId === document.id).map((f) => f.id);
db.fragments = db.fragments.filter((f) => f.documentId !== document.id);
db.links = db.links.filter((l) => !fragmentIds.includes(l.fromId) && !fragmentIds.includes(l.toId) && l.documentId !== document.id);
const usedConceptIds = new Set();
for (const link of db.links) {
if (link.toType === 'concept') usedConceptIds.add(link.toId);
if (link.fromType === 'concept') usedConceptIds.add(link.fromId);
}
db.concepts = db.concepts.filter((c) => usedConceptIds.has(c.id));
const fragments = splitParagraphs(document.text).map((text, index) => ({
id: stableId('frag', `document.id:index:text.slice(0, 80)`),
type: 'fragment',
documentId: document.id,
index,
text,
summary: text.slice(0, 100),
conceptNames: []
}));
const conceptMap = new Map(db.concepts.map((c) => [c.normalizedName, c]));
const links = [];
for (const fragment of fragments) {
const fragmentConcepts = extractConcepts(fragment.text).slice(0, 6);
fragment.conceptNames = fragmentConcepts.map((item) => item.normalizedName);
for (const concept of fragmentConcepts) {
if (!conceptMap.has(concept.normalizedName)) {
conceptMap.set(concept.normalizedName, {
id: stableId('concept', concept.normalizedName),
type: 'concept',
name: concept.name,
normalizedName: concept.normalizedName,
salience: Math.min(1, concept.count / 3)
});
}
const conceptNode = conceptMap.get(concept.normalizedName);
links.push({
id: stableId('link', `fragment.id->conceptNode.id`),
type: 'mentions',
fromId: fragment.id,
fromType: 'fragment',
toId: conceptNode.id,
toType: 'concept',
documentId: document.id,
score: concept.count
});
}
}
for (let i = 0; i < fragments.length - 1; i += 1) {
links.push({
id: stableId('link', `fragments[i].id->fragments[i + 1].id`),
type: 'adjacent',
fromId: fragments[i].id,
fromType: 'fragment',
toId: fragments[i + 1].id,
toType: 'fragment',
documentId: document.id,
score: 0.4
});
}
db.fragments.push(...fragments);
db.links.push(...links);
db.concepts = [...conceptMap.values()].sort((a, b) => a.normalizedName.localeCompare(b.normalizedName));
return { fragmentsCreated: fragments.length, linksCreated: links.length, conceptsTotal: db.concepts.length };
}
async function queryWithEmbedding(adapter, embeddingProvider, q, options = {}) {
const db = await adapter.load();
const query = normalizeConcept(q || '');
if (!query) throw new Error('Query is required');
const terms = query.split(' ').filter(Boolean);
let scored = db.fragments.map((fragment) => {
const textNorm = normalizeConcept(fragment.text);
let score = 0;
for (const term of terms) {
if (textNorm.includes(term)) score += 3;
if (fragment.conceptNames.includes(term)) score += 5;
}
return { fragment, score };
}).filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.fragment.index - b.fragment.index)
.slice(0, Number(options.limit || 3));
// Vector re-rank if real embedding provider (not KeywordEmbeddingProvider)
if (embeddingProvider && embeddingProvider.constructor.name !== 'KeywordEmbeddingProvider') {
try {
const queryVec = await embeddingProvider.embed(q);
const fragTexts = scored.length > 0 ? scored.map((s) => s.fragment.text) : db.fragments.slice(0, 20).map((f) => f.text);
const fragVecs = await embeddingProvider.embedBatch(fragTexts);
if (fragVecs.length > 0) {
const scoredWithVec = scored.length > 0
? scored.map((s, i) => ({ ...s, vecScore: embeddingProvider.similarity(queryVec, fragVecs[i]) }))
: db.fragments.slice(0, 20).map((f, i) => ({ fragment: f, score: 0, vecScore: embeddingProvider.similarity(queryVec, fragVecs[i]) }));
scoredWithVec.forEach((item) => {
item.blendedScore = item.score * 0.6 + item.vecScore * 10 * 0.4;
});
scored = scoredWithVec.sort((a, b) => b.blendedScore - a.blendedScore).slice(0, Number(options.limit || 3));
}
} catch {
// No API key or vector search failed, keep keyword results
}
}
const evidence = scored.map((item) => {
const doc = db.documents.find((d) => d.id === item.fragment.documentId);
return {
fragmentId: item.fragment.id,
documentId: item.fragment.documentId,
documentTitle: doc?.title || 'unknown',
score: item.score || item.vecScore,
text: item.fragment.text
};
});
const relatedConcepts = [...new Set(scored.flatMap((item) => item.fragment.conceptNames))]
.map((name) => db.concepts.find((c) => c.normalizedName === name))
.filter(Boolean)
.slice(0, 8)
.map((concept) => ({ id: concept.id, name: concept.name, normalizedName: concept.normalizedName }));
const answer = evidence.length
? `Found evidence.length relevant fragments for "q". Top evidence comes from [...new Set(evidence.map((item) => item.documentTitle))].join(', ').`
: `No strong match found for "q". Try a broader concept or ingest more documents.`;
return {
query: q,
answer,
evidence,
relatedConcepts,
stats: { documents: db.documents.length, fragments: db.fragments.length, concepts: db.concepts.length, links: db.links.length }
};
}
function parseArgs(argv) {
const result = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (!token.startsWith('--')) {
result._.push(token);
continue;
}
const [key, inline] = token.slice(2).split('=');
if (inline !== undefined) {
result[key] = inline;
continue;
}
const next = argv[i + 1];
if (!next || next.startsWith('--')) {
result[key] = true;
} else {
result[key] = next;
i += 1;
}
}
return result;
}
function printHelp() {
console.log(`LinkMind MVP CLI v0.2.0
Commands:
ingest --file <path> [--title <title>] [--sourceType <type>] [--storage json|sqlite]
query --q <text> [--limit <n>] [--embedding keyword|openai]
status [--storage json|sqlite]
reset [--storage json|sqlite]
help
Options:
--storage <adapter> Storage: json (default) or sqlite
--embedding <provider> Embedding: keyword (default) or openai
--db-path <path> Custom db path
Examples:
node dist/index.js ingest --file examples/sample-note.md --title "Sample"
node dist/index.js query --q "knowledge connector"
node dist/index.js status --storage sqlite
node dist/index.js reset --storage sqlite`);
}
async function main() {
const [, , command, ...rest] = process.argv;
const args = parseArgs(rest);
const storageType = args.storage || 'json';
const embeddingType = args.embedding || 'keyword';
const dbPathArg = args['db-path'] || undefined;
let adapter = null;
try {
if (!command || command === 'help' || args.help) {
printHelp();
return;
}
try {
const adaptersDir = IS_DIST ? 'storage-adapters' : 'src/storage-adapters';
if (storageType === 'sqlite') {
const { SqliteStorageAdapter } = require(path.join(THIS_DIR, adaptersDir, 'SqliteStorageAdapter.js'));
adapter = new SqliteStorageAdapter(dbPathArg ? { dbPath: dbPathArg } : {});
} else {
const { JsonStorageAdapter } = require(path.join(THIS_DIR, adaptersDir, 'JsonStorageAdapter.js'));
adapter = new JsonStorageAdapter(dbPathArg ? { dbPath: dbPathArg } : {});
}
} catch (e) {
if (e.message.includes('sqlite3 not installed')) {
console.error('[LinkMind] sqlite3 not installed. Run: npm install sqlite3');
} else {
console.error(`[LinkMind] Adapter load error: e.message`);
}
process.exitCode = 1;
return;
}
let embeddingProvider = null;
try {
const providersDir = IS_DIST ? 'embedding-providers' : 'src/embedding-providers';
const { MockProvider, OpenAICompatibleProvider } = require(path.join(THIS_DIR, providersDir, 'index.js'));
embeddingProvider = embeddingType === 'openai'
? new OpenAICompatibleProvider({ baseURL: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', apiKey: process.env.OPENAI_API_KEY || '', model: process.env.OPENAI_MODEL || 'text-embedding-3-small' })
: new MockProvider();
} catch {
embeddingProvider = null;
}
const now = () => new Date().toISOString();
if (command === 'ingest') {
if (!args.file) throw new Error('--file is required for ingest');
const full = path.resolve(process.cwd(), args.file);
if (!fs.existsSync(full)) throw new Error(`File not found: full`);
const text = fs.readFileSync(full, 'utf8');
const db = await adapter.load();
const doc = {
id: stableId('doc', `full:args.title || path.basename(full)`),
type: 'document',
title: args.title || path.basename(full),
sourceType: args.sourceType || 'file',
sourceUri: full,
importedAt: now(),
status: 'active',
text
};
upsertDocument(db, doc);
const stats = buildForDocument(db, doc);
await adapter.save(db);
console.log(JSON.stringify({ documentId: doc.id, title: doc.title, ...stats }, null, 2));
return;
}
if (command === 'query') {
if (!args.q && !args.query) throw new Error('--q is required for query');
const result = await queryWithEmbedding(adapter, embeddingProvider, args.q || args.query, { limit: args.limit });
console.log(JSON.stringify(result, null, 2));
return;
}
if (command === 'status') {
const db = await adapter.load();
console.log(JSON.stringify({
documents: db.documents.length,
fragments: db.fragments.length,
concepts: db.concepts.length,
links: db.links.length,
updatedAt: db.updatedAt,
adapter: storageType,
embedding: embeddingType
}, null, 2));
return;
}
if (command === 'reset') {
await adapter.clear();
console.log(JSON.stringify({ ok: true, adapter: storageType }, null, 2));
return;
}
throw new Error(`Unknown command: command`);
} catch (error) {
console.error(`[LinkMind] error.message`);
process.exitCode = 1;
} finally {
if (adapter) await adapter.close();
}
}
if (require.main === module) {
main();
}
module.exports = {
buildForDocument,
extractConcepts,
splitParagraphs,
normalizeConcept,
stableId,
queryWithEmbedding,
createEmptyDb
};
FILE:dist/retriever.js
/**
* retriever.js
* 关键词召回 + 向量相似度召回双层检索。
* ranker:余弦相似度
* 最终结果 = 关键词召回 ∪ 向量召回 → 去重排序
*/
const { normalizeConcept, STOPWORDS } = require('./utils/nlp');
/**
* @typedef {Object} RetrievalResult
* @property {string} fragmentId
* @property {string} documentId
* @property {string} documentTitle
* @property {number} score
* @property {string} text
* @property {'keyword'|'vector'|'hybrid'} source
*/
/**
* 计算余弦相似度
* @param {number[]} a
* @param {number[]} b
*/
function cosineSimilarity(a, b) {
if (a.length !== b.length) return 0;
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i += 1) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
const denom = Math.sqrt(na) * Math.sqrt(nb);
return denom === 0 ? 0 : dot / denom;
}
/**
* 关键词召回(从 fragment 列表)
*/
function keywordSearch(fragments, query, limit = 20) {
const terms = normalizeConcept(query)
.split(' ')
.filter(Boolean)
.filter((t) => !STOPWORDS.has(t));
if (terms.length === 0) return [];
return fragments
.map((frag) => {
const textNorm = normalizeConcept(frag.text);
let score = 0;
for (const term of terms) {
if (textNorm.includes(term)) score += 3;
if ((frag.conceptNames || []).includes(term)) score += 5;
}
return { frag, score };
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.frag.index - b.frag.index)
.slice(0, limit)
.map((item) => ({
fragmentId: item.frag.id,
documentId: item.frag.documentId,
documentTitle: item.frag.documentTitle || 'unknown',
score: item.score,
text: item.frag.text,
source: 'keyword'
}));
}
/**
* 向量相似度召回
* @param {object[]} fragments - 带 documentTitle
* @param {string} query
* @param {import('./embedding-providers/EmbeddingProvider')} embeddingProvider
* @param {number} limit
*/
async function vectorSearch(fragments, query, embeddingProvider, limit = 20) {
if (!embeddingProvider || fragments.length === 0) return [];
const texts = fragments.map((f) => f.text);
const [queryVec, fragVecs] = await Promise.all([
embeddingProvider.embed([query]),
embeddingProvider.embed(texts)
]);
const qv = queryVec[0];
return fragments
.map((frag, i) => ({
frag,
sim: cosineSimilarity(qv, fragVecs[i] || [])
}))
.filter((item) => item.sim > 0)
.sort((a, b) => b.sim - a.sim)
.slice(0, limit)
.map((item) => ({
fragmentId: item.frag.id,
documentId: item.frag.documentId,
documentTitle: item.frag.documentTitle || 'unknown',
score: item.sim,
text: item.frag.text,
source: 'vector'
}));
}
/**
* 合并关键词召回 + 向量召回,去重并排序
* @param {RetrievalResult[]} keywordResults
* @param {RetrievalResult[]} vectorResults
* @param {number} limit
*/
function mergeResults(keywordResults, vectorResults, limit = 10) {
const seen = new Map();
for (const r of [...keywordResults, ...vectorResults]) {
if (!seen.has(r.fragmentId)) {
seen.set(r.fragmentId, { ...r });
} else {
// 取最高分
const existing = seen.get(r.fragmentId);
existing.score = Math.max(existing.score, r.score);
existing.source = existing.source === r.source ? existing.source : 'hybrid';
}
}
return [...seen.values()]
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
/**
* 主检索入口
* @param {object} options
* @param {object[]} options.fragments - fragment 列表,需含 documentTitle
* @param {string} options.query
* @param {import('./embedding-providers/EmbeddingProvider')} [options.embeddingProvider]
* @param {number} [options.limit]
*/
async function retrieve({ fragments, query, embeddingProvider = null, limit = 10 }) {
const kw = keywordSearch(fragments, query, limit * 2);
const vec = embeddingProvider
? await vectorSearch(fragments, query, embeddingProvider, limit * 2)
: [];
return mergeResults(kw, vec, limit);
}
module.exports = {
keywordSearch,
vectorSearch,
mergeResults,
cosineSimilarity,
retrieve
};
FILE:dist/storage-adapters/JsonStorageAdapter.js
/**
* JsonStorageAdapter
* 基于本地 JSON 文件的存储实现,保持向后兼容。
*/
const fs = require('fs');
const path = require('path');
const { StorageAdapter } = require('./StorageAdapter');
const ROOT = path.resolve(__dirname, '../..');
const DATA_DIR = path.join(ROOT, 'data');
const DB_PATH = path.join(DATA_DIR, 'workspace.json');
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function now() {
return new Date().toISOString();
}
class JsonStorageAdapter extends StorageAdapter {
constructor() {
super();
this._db = null;
}
_loadDb() {
ensureDir(DATA_DIR);
if (!fs.existsSync(DB_PATH)) {
const empty = this._emptyDb();
this._saveDb(empty);
return empty;
}
return JSON.parse(fs.readFileSync(DB_PATH, 'utf8'));
}
_saveDb(db) {
db.updatedAt = now();
ensureDir(DATA_DIR);
fs.writeFileSync(DB_PATH, JSON.stringify(db, null, 2), 'utf8');
}
_emptyDb() {
return { version: 1, createdAt: now(), updatedAt: now(), documents: [], fragments: [], concepts: [], links: [] };
}
async init() {
this._db = this._loadDb();
}
// --- Document ---
async saveDocument(doc) {
const db = this._loadDb();
const idx = db.documents.findIndex((d) => d.id === doc.id);
if (idx >= 0) db.documents[idx] = doc;
else db.documents.push(doc);
this._saveDb(db);
return doc;
}
async getDocument(id) {
const db = this._loadDb();
return db.documents.find((d) => d.id === id) || null;
}
async listDocuments() {
const db = this._loadDb();
return db.documents;
}
async deleteDocument(id) {
const db = this._loadDb();
db.documents = db.documents.filter((d) => d.id !== id);
this._saveDb(db);
}
// --- Fragment ---
async saveFragment(frag) {
const db = this._loadDb();
const idx = db.fragments.findIndex((f) => f.id === frag.id);
if (idx >= 0) db.fragments[idx] = frag;
else db.fragments.push(frag);
this._saveDb(db);
return frag;
}
async getFragment(id) {
const db = this._loadDb();
return db.fragments.find((f) => f.id === id) || null;
}
async listFragments(documentId) {
const db = this._loadDb();
return db.fragments.filter((f) => f.documentId === documentId);
}
async deleteFragmentsByDocument(documentId) {
const db = this._loadDb();
db.fragments = db.fragments.filter((f) => f.documentId !== documentId);
this._saveDb(db);
}
async saveFragments(fragments) {
const db = this._loadDb();
for (const frag of fragments) {
const idx = db.fragments.findIndex((f) => f.id === frag.id);
if (idx >= 0) db.fragments[idx] = frag;
else db.fragments.push(frag);
}
this._saveDb(db);
}
// --- Concept ---
async saveConcept(concept) {
const db = this._loadDb();
const idx = db.concepts.findIndex((c) => c.id === concept.id);
if (idx >= 0) db.concepts[idx] = concept;
else db.concepts.push(concept);
this._saveDb(db);
return concept;
}
async getConcept(id) {
const db = this._loadDb();
return db.concepts.find((c) => c.id === id) || null;
}
async listConcepts() {
const db = this._loadDb();
return db.concepts;
}
async upsertConcept(concept) {
const db = this._loadDb();
const idx = db.concepts.findIndex((c) => c.id === concept.id);
if (idx >= 0) db.concepts[idx] = concept;
else db.concepts.push(concept);
this._saveDb(db);
return concept;
}
async saveConcepts(concepts) {
const db = this._loadDb();
for (const c of concepts) {
const idx = db.concepts.findIndex((x) => x.id === c.id);
if (idx >= 0) db.concepts[idx] = c;
else db.concepts.push(c);
}
this._saveDb(db);
}
// --- Link ---
async saveLink(link) {
const db = this._loadDb();
const idx = db.links.findIndex((l) => l.id === link.id);
if (idx >= 0) db.links[idx] = link;
else db.links.push(link);
this._saveDb(db);
return link;
}
async getLinks(fromId, toId) {
const db = this._loadDb();
return db.links.filter(
(l) => (fromId ? l.fromId === fromId : true) && (toId ? l.toId === toId : true)
);
}
async deleteLinksByDocument(documentId) {
const db = this._loadDb();
db.links = db.links.filter((l) => l.documentId !== documentId);
this._saveDb(db);
}
async saveLinks(links) {
const db = this._loadDb();
for (const l of links) {
const idx = db.links.findIndex((x) => x.id === l.id);
if (idx >= 0) db.links[idx] = l;
else db.links.push(l);
}
this._saveDb(db);
}
// --- Full DB operations (for CLI compat) ---
async load() {
return this._loadDb();
}
async save(db) {
this._saveDb(db);
}
async close() {
// no-op for JSON
}
// --- Workspace ---
async clear() {
this._saveDb(this._emptyDb());
}
// --- Stats ---
getStats() {
const db = this._loadDb();
return {
documents: db.documents.length,
fragments: db.fragments.length,
concepts: db.concepts.length,
links: db.links.length,
updatedAt: db.updatedAt,
dbPath: DB_PATH
};
}
}
module.exports = { JsonStorageAdapter };
FILE:dist/storage-adapters/SqliteStorageAdapter.js
/**
* SqliteStorageAdapter
* 基于 better-sqlite3 的 SQLite 存储实现。
* 建表语句 + 所有 StorageAdapter 方法的完整实现。
*/
const { StorageAdapter } = require('./StorageAdapter');
let sqlite3 = null;
try {
sqlite3 = require('better-sqlite3');
} catch {
// better-sqlite3 not installed — adapter will throw on init()
}
const ROOT = path => require('path').resolve(__dirname, '../..');
const DATA_DIR = path.join(ROOT, 'data');
const DB_PATH = path.join(DATA_DIR, 'db.sqlite');
function ensureDir(dir) {
require('fs').mkdirSync(dir, { recursive: true });
}
function now() {
return new Date().toISOString();
}
const SCHEMA = `
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'document',
title TEXT,
sourceType TEXT,
sourceUri TEXT,
importedAt TEXT,
status TEXT DEFAULT 'active',
text TEXT,
createdAt TEXT,
updatedAt TEXT
);
CREATE TABLE IF NOT EXISTS fragments (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'fragment',
documentId TEXT,
"index" INTEGER,
text TEXT,
summary TEXT,
conceptNames TEXT,
createdAt TEXT,
FOREIGN KEY (documentId) REFERENCES documents(id)
);
CREATE TABLE IF NOT EXISTS concepts (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'concept',
name TEXT,
normalizedName TEXT UNIQUE,
salience REAL,
createdAt TEXT
);
CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY,
type TEXT,
fromId TEXT,
fromType TEXT,
toId TEXT,
toType TEXT,
documentId TEXT,
score REAL,
createdAt TEXT,
FOREIGN KEY (documentId) REFERENCES documents(id)
);
CREATE TABLE IF NOT EXISTS fragment_vectors (
fragmentId TEXT PRIMARY KEY,
vector BLOB,
updatedAt TEXT,
FOREIGN KEY (fragmentId) REFERENCES fragments(id)
);
CREATE INDEX IF NOT EXISTS idx_fragments_documentId ON fragments(documentId);
CREATE INDEX IF NOT EXISTS idx_links_fromId ON links(fromId);
CREATE INDEX IF NOT EXISTS idx_links_toId ON links(toId);
CREATE INDEX IF NOT EXISTS idx_links_documentId ON links(documentId);
CREATE INDEX IF NOT EXISTS idx_concepts_normalizedName ON concepts(normalizedName);
`;
class SqliteStorageAdapter extends StorageAdapter {
constructor({ dbPath } = {}) {
super();
this._dbPath = dbPath || DB_PATH;
this._db = null;
}
async init() {
if (!sqlite3) {
throw new Error('better-sqlite3 is not installed. Run: npm install better-sqlite3');
}
ensureDir(require('path').dirname(this._dbPath));
this._db = sqlite3(this._dbPath);
this._db.pragma('journal_mode = WAL');
this._db.exec(SCHEMA);
}
_run(sql, params = []) {
try {
return this._db.prepare(sql).run(...params);
} catch (e) {
throw new Error(`SQLite run error: e.message | sql: sql`);
}
}
_all(sql, params = []) {
try {
return this._db.prepare(sql).all(...params);
} catch (e) {
throw new Error(`SQLite all error: e.message | sql: sql`);
}
}
_get(sql, params = []) {
try {
return this._db.prepare(sql).get(...params);
} catch (e) {
throw new Error(`SQLite get error: e.message | sql: sql`);
}
}
// --- Document ---
async saveDocument(doc) {
this._run(
`INSERT OR REPLACE INTO documents (id,type,title,sourceType,sourceUri,importedAt,status,text,createdAt,updatedAt)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
[doc.id, doc.type || 'document', doc.title || '', doc.sourceType || '', doc.sourceUri || '',
doc.importedAt || now(), doc.status || 'active', doc.text || '',
doc.createdAt || now(), now()]
);
return doc;
}
async getDocument(id) {
const row = this._get('SELECT * FROM documents WHERE id = ?', [id]);
return row ? this._mapDoc(row) : null;
}
async listDocuments() {
return this._all('SELECT * FROM documents ORDER BY importedAt DESC').map(this._mapDoc);
}
async deleteDocument(id) {
this._run('DELETE FROM documents WHERE id = ?', [id]);
}
// --- Fragment ---
async saveFragment(frag) {
this._run(
`INSERT OR REPLACE INTO fragments (id,type,documentId,"index",text,summary,conceptNames,createdAt)
VALUES (?,?,?,?,?,?,?,?)`,
[frag.id, 'fragment', frag.documentId, frag.index, frag.text, frag.summary || frag.text.slice(0, 100),
JSON.stringify(frag.conceptNames || []), frag.createdAt || now()]
);
return frag;
}
async getFragment(id) {
const row = this._get('SELECT * FROM fragments WHERE id = ?', [id]);
return row ? this._mapFrag(row) : null;
}
async listFragments(documentId) {
return this._all('SELECT * FROM fragments WHERE documentId = ? ORDER BY "index" ASC', [documentId])
.map(this._mapFrag);
}
async deleteFragmentsByDocument(documentId) {
this._run('DELETE FROM fragments WHERE documentId = ?', [documentId]);
}
async saveFragments(fragments) {
const stmt = this._db.prepare(
`INSERT OR REPLACE INTO fragments (id,type,documentId,"index",text,summary,conceptNames,createdAt)
VALUES (?,?,?,?,?,?,?,?)`
);
const insertMany = this._db.transaction((items) => {
for (const frag of items) {
stmt.run(frag.id, 'fragment', frag.documentId, frag.index, frag.text,
frag.summary || frag.text.slice(0, 100), JSON.stringify(frag.conceptNames || []),
frag.createdAt || now());
}
});
insertMany(fragments);
return fragments;
}
// --- Concept ---
async saveConcept(concept) {
this._run(
`INSERT OR REPLACE INTO concepts (id,type,name,normalizedName,salience,createdAt)
VALUES (?,?,?,?,?,?)`,
[concept.id, 'concept', concept.name, concept.normalizedName, concept.salience || 0,
concept.createdAt || now()]
);
return concept;
}
async getConcept(id) {
const row = this._get('SELECT * FROM concepts WHERE id = ?', [id]);
return row ? this._mapConcept(row) : null;
}
async listConcepts() {
return this._all('SELECT * FROM concepts ORDER BY normalizedName ASC').map(this._mapConcept);
}
async upsertConcept(concept) {
return this.saveConcept(concept);
}
async saveConcepts(concepts) {
const stmt = this._db.prepare(
`INSERT OR REPLACE INTO concepts (id,type,name,normalizedName,salience,createdAt) VALUES (?,?,?,?,?,?)`
);
const insertMany = this._db.transaction((items) => {
for (const c of items) {
stmt.run(c.id, 'concept', c.name, c.normalizedName, c.salience || 0, c.createdAt || now());
}
});
insertMany(concepts);
return concepts;
}
// --- Link ---
async saveLink(link) {
this._run(
`INSERT OR REPLACE INTO links (id,type,fromId,fromType,toId,toType,documentId,score,createdAt)
VALUES (?,?,?,?,?,?,?,?,?)`,
[link.id, link.type, link.fromId, link.fromType, link.toId, link.toType,
link.documentId, link.score || 0, link.createdAt || now()]
);
return link;
}
async getLinks(fromId, toId) {
let sql = 'SELECT * FROM links WHERE 1=1';
const params = [];
if (fromId) { sql += ' AND fromId = ?'; params.push(fromId); }
if (toId) { sql += ' AND toId = ?'; params.push(toId); }
return this._all(sql, params);
}
async deleteLinksByDocument(documentId) {
this._run('DELETE FROM links WHERE documentId = ?', [documentId]);
}
async saveLinks(links) {
const stmt = this._db.prepare(
`INSERT OR REPLACE INTO links (id,type,fromId,fromType,toId,toType,documentId,score,createdAt)
VALUES (?,?,?,?,?,?,?,?,?)`
);
const insertMany = this._db.transaction((items) => {
for (const l of items) {
stmt.run(l.id, l.type, l.fromId, l.fromType, l.toId, l.toType,
l.documentId, l.score || 0, l.createdAt || now());
}
});
insertMany(links);
return links;
}
// --- Vector ---
async saveFragmentVector(fragmentId, vector) {
const buf = Buffer.from(JSON.stringify(vector));
this._run(
`INSERT OR REPLACE INTO fragment_vectors (fragmentId,vector,updatedAt) VALUES (?,?,?)`,
[fragmentId, buf, now()]
);
}
async getFragmentVector(fragmentId) {
const row = this._get('SELECT vector FROM fragment_vectors WHERE fragmentId = ?', [fragmentId]);
if (!row) return null;
return JSON.parse(row.vector);
}
async listFragmentVectors() {
return this._all('SELECT fragmentId, vector FROM fragment_vectors').map((r) => ({
fragmentId: r.fragmentId,
vector: JSON.parse(r.vector)
}));
}
// --- Full DB operations (for CLI compat) ---
async load() {
const docs = this._all('SELECT * FROM documents ORDER BY importedAt DESC').map(this._mapDoc);
const frags = this._all('SELECT * FROM fragments ORDER BY "index" ASC').map(this._mapFrag);
const concepts = this._all('SELECT * FROM concepts ORDER BY normalizedName ASC').map(this._mapConcept);
const links = this._all('SELECT * FROM links');
const updatedAt = this._get('SELECT MAX(updatedAt) as t FROM documents UNION ALL SELECT MAX(updatedAt) as t FROM fragments')?.t || new Date().toISOString();
return { version: 1, documents: docs, fragments: frags, concepts, links, updatedAt };
}
async save(db) {
await this.clear();
if (db.documents) for (const d of db.documents) await this.saveDocument(d);
if (db.fragments) for (const f of db.fragments) await this.saveFragment(f);
if (db.concepts) for (const c of db.concepts) await this.saveConcept(c);
if (db.links) for (const l of db.links) await this.saveLink(l);
}
async close() {
if (this._db) { this._db.close(); this._db = null; }
}
// --- Workspace ---
async clear() {
this._run('DELETE FROM fragment_vectors');
this._run('DELETE FROM links');
this._run('DELETE FROM fragments');
this._run('DELETE FROM concepts');
this._run('DELETE FROM documents');
}
getStats() {
return {
documents: this._get('SELECT COUNT(*) as n FROM documents')?.n || 0,
fragments: this._get('SELECT COUNT(*) as n FROM fragments')?.n || 0,
concepts: this._get('SELECT COUNT(*) as n FROM concepts')?.n || 0,
links: this._get('SELECT COUNT(*) as n FROM links')?.n || 0,
dbPath: this._dbPath
};
}
// --- Mappers ---
_mapDoc(row) {
return { ...row };
}
_mapFrag(row) {
return { ...row, conceptNames: JSON.parse(row.conceptNames || '[]') };
}
_mapConcept(row) {
return { ...row };
}
}
module.exports = { SqliteStorageAdapter };
FILE:dist/storage-adapters/StorageAdapter.js
/**
* StorageAdapter Interface
* 所有 storage adapter 必须实现此接口契约。
*/
class StorageAdapter {
/**
* @returns {Promise<void>}
*/
async init() {
throw new Error('Not implemented');
}
// --- Document ---
async saveDocument(doc) {
throw new Error('Not implemented');
}
async getDocument(id) {
throw new Error('Not implemented');
}
async listDocuments() {
throw new Error('Not implemented');
}
async deleteDocument(id) {
throw new Error('Not implemented');
}
// --- Fragment ---
async saveFragment(frag) {
throw new Error('Not implemented');
}
async getFragment(id) {
throw new Error('Not implemented');
}
async listFragments(documentId) {
throw new Error('Not implemented');
}
async deleteFragmentsByDocument(documentId) {
throw new Error('Not implemented');
}
// --- Concept ---
async saveConcept(concept) {
throw new Error('Not implemented');
}
async getConcept(id) {
throw new Error('Not implemented');
}
async listConcepts() {
throw new Error('Not implemented');
}
async upsertConcept(concept) {
throw new Error('Not implemented');
}
// --- Link ---
async saveLink(link) {
throw new Error('Not implemented');
}
async getLinks(fromId, toId) {
throw new Error('Not implemented');
}
async deleteLinksByDocument(documentId) {
throw new Error('Not implemented');
}
// --- Bulk ---
async saveFragments(fragments) {
throw new Error('Not implemented');
}
async saveConcepts(concepts) {
throw new Error('Not implemented');
}
async saveLinks(links) {
throw new Error('Not implemented');
}
// --- Workspace ---
async clear() {
throw new Error('Not implemented');
}
}
module.exports = { StorageAdapter };
FILE:dist/storage-adapters/index.js
/**
* storage-adapters/index.js
* Factory: 根据配置返回对应的 storage adapter 实例。
*/
const { JsonStorageAdapter } = require('./JsonStorageAdapter');
const { SqliteStorageAdapter } = require('./SqliteStorageAdapter');
function createStorageAdapter(type = 'json', options = {}) {
switch (type) {
case 'json':
return new JsonStorageAdapter(options);
case 'sqlite':
return new SqliteStorageAdapter(options);
default:
throw new Error(`Unknown storage adapter type: type. Use 'json' or 'sqlite'.`);
}
}
module.exports = { createStorageAdapter, JsonStorageAdapter, SqliteStorageAdapter };
FILE:dist/utils/nlp.js
/**
* nlp.js - 文本规范化工具
*/
const STOPWORDS = new Set([
'the','and','for','that','this','with','from','into','your','have','will','not','are','was','were','can','could','should','about','what','when','where','which','while','than','then','them','they','their','there','here','also','more','most','some','such','using','used','use','make','made','over','under','very','just','only','each','been','being','does','did','done','how','why','our','you','its','his','her','she','him','who','has','had','but','too','via','per','one','two','three',
'我们','你们','他们','这个','那个','一个','一种','可以','需要','如果','因为','所以','以及','进行','通过','没有','不是','就是','如何','什么','为什么','时候','今天','已经','还有','对于','一些','这种','那些','并且','或者','主要','用户','系统','功能','能力','作为','实现','相关','用于'
]);
function normalizeConcept(text) {
if (!text) return '';
return text
.toLowerCase()
.replace(/[\u2018\u2019'"""''()()【】\[\],.:;!?/\\]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
module.exports = { normalizeConcept, STOPWORDS };
FILE:examples/sample-note.md
# LinkMind sample note
LinkMind is a connected knowledge system. It ingests notes and documents, splits them into fragments, extracts concepts, and builds explainable links.
The MVP focuses on a local JSON workspace. This keeps the implementation simple while preserving a future path toward vector retrieval, graph storage, and richer evidence assembly.
Knowledge Connector is the capability layer. LinkMind is the user-facing product layer built on top of it.
Good retrieval should return answer, evidence, and related concepts instead of a single opaque paragraph.
FILE:package.json
{
"name": "linkmind",
"version": "0.2.0",
"description": "LinkMind MVP skeleton - local knowledge connector with ingest/build/query CLI",
"main": "dist/index.js",
"bin": {
"linkmind": "dist/index.js"
},
"scripts": {
"build": "cp -r src/storage-adapters dist/ && cp -r src/embedding-providers dist/ && cp -r src/utils dist/ && cp src/index.js dist/ && cp src/retriever.js dist/ && chmod +x dist/index.js",
"test": "node tests/smoke-test.js"
},
"keywords": ["knowledge", "graph", "rag", "linkmind"],
"author": "Golden Bean",
"license": "MIT",
"optionalDependencies": {
"sqlite3": "^5.1.7"
}
}
FILE:skill.json
{
"name": "linkmind",
"version": "1.0.0",
"description": "知识连接引擎 - 本地化知识中枢 CLI 工具,支持 storage adapter 抽象层和 OpenAI-compatible embedding provider",
"keywords": ["linkmind", "knowledge-management", "embedding", "local-knowledge", "cli", "vector-search"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/linkmind",
"language": ["en", "zh"],
"tags": ["knowledge-management", "embedding", "local-knowledge", "cli"],
"createdAt": "2026-04-04",
"updatedAt": "2026-04-05"
}
FILE:src/adapters/base.js
/**
* StorageAdapter 接口契约
* 所有 adapter 必须实现以下方法:
* load() -> Promise<WorkspaceDb>
* save(db) -> Promise<void>
* clear() -> Promise<void>
* query(fn) -> Promise<any> (事务查询)
* close() -> Promise<void>
*/
class StorageAdapter {
async load() {
throw new Error('Not implemented: load()');
}
async save(db) {
throw new Error('Not implemented: save(db)');
}
async clear() {
throw new Error('Not implemented: clear()');
}
async query(fn) {
throw new Error('Not implemented: query(fn)');
}
async close() {
throw new Error('Not implemented: close()');
}
}
module.exports = { StorageAdapter };
FILE:src/adapters/json.js
/**
* JsonStorageAdapter
* 保持现有 JSON 文件行为,完全兼容旧数据格式。
*/
const fs = require('fs');
const path = require('path');
const { StorageAdapter } = require('./base');
const ROOT = __dirname.includes(`path.sepdist`)
? path.resolve(__dirname, '../..')
: path.resolve(__dirname, '../..');
const DATA_DIR = path.join(ROOT, 'data');
const DB_PATH = path.join(DATA_DIR, 'workspace.json');
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
class JsonStorageAdapter extends StorageAdapter {
constructor(opts = {}) {
super();
this.dbPath = opts.dbPath || DB_PATH;
this.dataDir = opts.dataDir || DATA_DIR;
}
_createEmpty() {
return {
version: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
documents: [],
fragments: [],
concepts: [],
links: []
};
}
async load() {
ensureDir(this.dataDir);
if (!fs.existsSync(this.dbPath)) {
const empty = this._createEmpty();
await this.save(empty);
return empty;
}
return JSON.parse(fs.readFileSync(this.dbPath, 'utf8'));
}
async save(db) {
db.updatedAt = new Date().toISOString();
ensureDir(this.dataDir);
fs.writeFileSync(this.dbPath, JSON.stringify(db, null, 2), 'utf8');
}
async clear() {
await this.save(this._createEmpty());
}
async query(fn) {
// JSON adapter 不需要事务,直接执行
const db = await this.load();
return fn(db);
}
async close() {
// 无需关闭
}
dbPath() {
return this.dbPath;
}
}
module.exports = { JsonStorageAdapter };
FILE:src/adapters/sqlite.js
/**
* SqliteStorageAdapter
* SQLite 存储适配器骨架,表结构与 JSON 格式等效。
* 事务支持、批量操作能力。
*/
const path = require('path');
const { StorageAdapter } = require('./base');
// sqlite3 为可选依赖,运行时检测
let sqlite3 = null;
try {
sqlite3 = require('sqlite3');
} catch {
// sqlite3 不可用,稍后通过 init() 检测并给出明确错误
}
const ROOT = __dirname.includes(`path.sepdist`)
? path.resolve(__dirname, '../..')
: path.resolve(__dirname, '../..');
const DATA_DIR = path.resolve(ROOT, 'data');
const SCHEMA = `
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'document',
title TEXT,
source_type TEXT,
source_uri TEXT,
imported_at TEXT,
status TEXT DEFAULT 'active',
text TEXT,
metadata TEXT
);
CREATE TABLE IF NOT EXISTS fragments (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'fragment',
document_id TEXT,
frag_index INTEGER,
text TEXT,
summary TEXT,
concept_names TEXT,
metadata TEXT,
FOREIGN KEY (document_id) REFERENCES documents(id)
);
CREATE TABLE IF NOT EXISTS concepts (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'concept',
name TEXT,
normalized_name TEXT UNIQUE,
salience REAL,
metadata TEXT
);
CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY,
type TEXT,
from_id TEXT,
from_type TEXT,
to_id TEXT,
to_type TEXT,
document_id TEXT,
score REAL,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_fragments_document ON fragments(document_id);
CREATE INDEX IF NOT EXISTS idx_links_from ON links(from_id);
CREATE INDEX IF NOT EXISTS idx_links_to ON links(to_id);
CREATE INDEX IF NOT EXISTS idx_concepts_normalized ON concepts(normalized_name);
`;
class SqliteStorageAdapter extends StorageAdapter {
constructor(opts = {}) {
super();
if (!sqlite3) {
throw new Error('sqlite3 not installed. Run: npm install sqlite3');
}
this.dbPath = opts.dbPath || path.join(DATA_DIR, 'workspace.db');
this._db = null;
}
async init() {
if (this._db) return;
return new Promise((resolve, reject) => {
this._db = new sqlite3.Database(this.dbPath, (err) => {
if (err) return reject(err);
this._db.exec(SCHEMA, (execErr) => {
if (execErr) return reject(execErr);
resolve();
});
});
});
}
async load() {
await this.init();
return new Promise((resolve, reject) => {
const docStmt = this._db.prepare('SELECT * FROM documents');
const fragStmt = this._db.prepare('SELECT * FROM fragments');
const conceptStmt = this._db.prepare('SELECT * FROM concepts');
const linkStmt = this._db.prepare('SELECT * FROM links');
const rows = { documents: [], fragments: [], concepts: [], links: [] };
const finish = () => {
// 反序列化 JSON 字段
rows.fragments = rows.fragments.map((f) => ({
...f,
id: f.id,
type: 'fragment',
documentId: f.document_id,
index: f.frag_index,
conceptNames: f.concept_names ? JSON.parse(f.concept_names) : []
}));
rows.concepts = rows.concepts.map((c) => ({
...c,
type: 'concept',
normalizedName: c.normalized_name
}));
rows.links = rows.links.map((l) => ({
...l,
fromId: l.from_id,
fromType: l.from_type,
toId: l.to_id,
toType: l.to_type,
documentId: l.document_id
}));
resolve(rows);
};
let pending = 4;
const done = () => { if (--pending === 0) finish(); };
docStmt.all((err, docs) => {
if (err) { docStmt.close(); return reject(err); }
rows.documents = docs.map((d) => ({ ...d, type: 'document' }));
docStmt.close();
done();
});
fragStmt.all((err, frags) => {
if (err) { fragStmt.close(); return reject(err); }
rows.fragments = frags;
fragStmt.close();
done();
});
conceptStmt.all((err, concepts) => {
if (err) { conceptStmt.close(); return reject(err); }
rows.concepts = concepts;
conceptStmt.close();
done();
});
linkStmt.all((err, links) => {
if (err) { linkStmt.close(); return reject(err); }
rows.links = links;
linkStmt.close();
done();
});
});
}
async save(rows) {
await this.init();
return new Promise((resolve, reject) => {
this._db.serialize(() => {
const insertDoc = this._db.prepare(
'INSERT OR REPLACE INTO documents (id,type,title,source_type,source_uri,imported_at,status,text,metadata) VALUES (?,?,?,?,?,?,?,?,?)'
);
const insertFrag = this._db.prepare(
'INSERT OR REPLACE INTO fragments (id,type,document_id,frag_index,text,summary,concept_names,metadata) VALUES (?,?,?,?,?,?,?,?)'
);
const insertConcept = this._db.prepare(
'INSERT OR REPLACE INTO concepts (id,type,name,normalized_name,salience,metadata) VALUES (?,?,?,?,?,?)'
);
const insertLink = this._db.prepare(
'INSERT OR REPLACE INTO links (id,type,from_id,from_type,to_id,to_type,document_id,score,metadata) VALUES (?,?,?,?,?,?,?,?,?)'
);
try {
for (const d of rows.documents) {
insertDoc.run(d.id, d.type, d.title, d.sourceType, d.sourceUri, d.importedAt, d.status, d.text, d.metadata ? JSON.stringify(d.metadata) : null);
}
for (const f of rows.fragments) {
insertFrag.run(f.id, f.type, f.documentId, f.index, f.text, f.summary, JSON.stringify(f.conceptNames || []), f.metadata ? JSON.stringify(f.metadata) : null);
}
for (const c of rows.concepts) {
insertConcept.run(c.id, c.type, c.name, c.normalizedName, c.salience, c.metadata ? JSON.stringify(c.metadata) : null);
}
for (const l of rows.links) {
insertLink.run(l.id, l.type, l.fromId, l.fromType, l.toId, l.toType, l.documentId, l.score, l.metadata ? JSON.stringify(l.metadata) : null);
}
resolve();
} catch (err) {
reject(err);
} finally {
insertDoc.close();
insertFrag.close();
insertConcept.close();
insertLink.close();
}
});
});
}
async clear() {
await this.init();
return new Promise((resolve, reject) => {
this._db.run('DELETE FROM links; DELETE FROM fragments; DELETE FROM concepts; DELETE FROM documents;', function (err) {
if (err) return reject(err);
resolve();
});
});
}
async query(fn) {
// 供未来事务查询使用,当前与 load 行为相同
const db = await this.load();
return fn(db);
}
async close() {
if (this._db) {
return new Promise((resolve) => {
this._db.close(() => resolve());
this._db = null;
});
}
}
}
module.exports = { SqliteStorageAdapter };
FILE:src/embedding-providers/EmbeddingProvider.js
/**
* EmbeddingProvider Interface
* 所有 embedding provider 必须实现此接口契约。
*/
class EmbeddingProvider {
/**
* 将文本列表转为向量
* @param {string[]} texts
* @returns {Promise<number[][]>}
*/
async embed(texts) {
throw new Error('Not implemented');
}
/**
* 返回 provider 名称
*/
get name() {
return 'unknown';
}
/**
* 返回向量维度
*/
get dimension() {
throw new Error('Not implemented');
}
}
module.exports = { EmbeddingProvider };
FILE:src/embedding-providers/MockProvider.js
/**
* MockProvider
* 返回随机向量,用于测试和离线开发。
*/
const { EmbeddingProvider } = require('./EmbeddingProvider');
const DEFAULT_DIM = 1536;
class MockProvider extends EmbeddingProvider {
constructor({ dimension = DEFAULT_DIM, seed = 42 } = {}) {
super();
this._dim = dimension;
this._seed = seed;
this._cache = new Map();
}
get name() {
return 'mock';
}
get dimension() {
return this._dim;
}
_pseudoRandom(text) {
let hash = 0;
for (let i = 0; i < text.length; i += 1) {
hash = ((hash << 5) - hash + text.charCodeAt(i)) | 0;
}
return Math.abs(hash) / 0x7fffffff;
}
async embed(texts) {
return texts.map((text) => {
if (this._cache.has(text)) return this._cache.get(text);
const vec = [];
for (let i = 0; i < this._dim; i += 1) {
// deterministic random based on text + index
const base = this._pseudoRandom(text + i);
vec.push(base);
}
// L2 normalize
const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0));
const normalized = norm > 0 ? vec.map((v) => v / norm) : vec;
this._cache.set(text, normalized);
return normalized;
});
}
}
module.exports = { MockProvider };
FILE:src/embedding-providers/OpenAICompatibleProvider.js
/**
* OpenAICompatibleProvider
* 调用 OpenAI-compatible API endpoint(如 vLLM、Ollama、Azure OpenAI 等)。
*/
const { EmbeddingProvider } = require('./EmbeddingProvider');
const DEFAULT_DIM = 1536;
class OpenAICompatibleProvider extends EmbeddingProvider {
constructor({ baseURL = 'https://api.openai.com/v1', apiKey = '', model = 'text-embedding-3-small', dimension = DEFAULT_DIM, batchSize = 100 } = {}) {
super();
this._baseURL = baseURL.replace(/\/$/, '');
this._apiKey = apiKey;
this._model = model;
this._dim = dimension;
this._batchSize = batchSize;
}
get name() {
return `openai-compatible:this._model`;
}
get dimension() {
return this._dim;
}
async embed(texts) {
if (!texts || texts.length === 0) return [];
const results = [];
for (let i = 0; i < texts.length; i += this._batchSize) {
const batch = texts.slice(i, i + this._batchSize);
const vectors = await this._fetchBatch(batch);
results.push(...vectors);
}
return results;
}
async _fetchBatch(batch) {
const url = `this._baseURL/embeddings`;
const body = {
model: this._model,
input: batch
};
const headers = {
'Content-Type': 'application/json'
};
if (this._apiKey) {
headers['Authorization'] = `Bearer this._apiKey`;
}
const resp = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body)
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
throw new Error(`Embedding API error resp.status: text`);
}
const json = await resp.json();
if (!json.data || !Array.isArray(json.data)) {
throw new Error(`Unexpected embedding response format`);
}
// sort by index to maintain order
const sorted = json.data.slice().sort((a, b) => a.index - b.index);
return sorted.map((item) => {
if (!item.embedding || !Array.isArray(item.embedding)) {
throw new Error(`Invalid embedding vector in response`);
}
return item.embedding;
});
}
}
module.exports = { OpenAICompatibleProvider };
FILE:src/embedding-providers/index.js
/**
* embedding-providers/index.js
* Factory: 根据配置返回对应的 embedding provider 实例。
*/
const { MockProvider } = require('./MockProvider');
const { OpenAICompatibleProvider } = require('./OpenAICompatibleProvider');
function createEmbeddingProvider(type = 'mock', options = {}) {
switch (type) {
case 'mock':
return new MockProvider(options);
case 'openai':
return new OpenAICompatibleProvider(options);
default:
throw new Error(`Unknown embedding provider type: type. Use 'mock' or 'openai'.`);
}
}
module.exports = { createEmbeddingProvider, MockProvider, OpenAICompatibleProvider };
FILE:src/index.js
#!/usr/bin/env node
/**
* LinkMind CLI - 知识连接引擎
* 支持 adapter 切换(json/sqlite)和 embedding 切换(keyword/openai)
*/
const fs = require('fs');
const path = require('path');
const THIS_DIR = __dirname.includes(`path.sepdist`) ? __dirname : __dirname;
const IS_DIST = THIS_DIR.includes(`path.sepdist`);
const ROOT = IS_DIST ? path.resolve(THIS_DIR, '..') : path.resolve(THIS_DIR, '..');
const DATA_DIR = path.join(ROOT, 'data');
function createEmptyDb() {
return {
version: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
documents: [],
fragments: [],
concepts: [],
links: []
};
}
function stableId(prefix, input) {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
}
return `prefix_Math.abs(hash).toString(36)`;
}
function normalizeConcept(text) {
return text
.toLowerCase()
.replace(/[\u2018\u2019'"""''()()【】\[\],.:;!?/\\]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
const STOPWORDS = new Set([
'the','and','for','that','this','with','from','into','your','have','will','not','are','was','were',
'can','could','should','about','what','when','where','which','while','than','then','them','they',
'their','there','here','also','more','most','some','such','using','used','use','make','made',
'over','under','very','just','only','each','been','being','does','did','done','how','why',
'our','you','its','his','her','she','him','who','has','had','but','too','via','per',
'one','two','three',
'我们','你们','他们','这个','那个','一个','一种','可以','需要','如果','因为','所以','以及',
'进行','通过','没有','不是','就是','如何','什么','为什么','时候','今天','已经','还有',
'对于','一些','这种','那些','并且','或者','主要','用户','系统','功能','能力','作为'
]);
function extractConcepts(text) {
const zhMatches = text.match(/[\u4e00-\u9fa5]{2,8}/g) || [];
const enMatches = text.match(/[A-Za-z][A-Za-z0-9_-]{2,}/g) || [];
const tokens = [...zhMatches, ...enMatches]
.map((item) => item.trim())
.map((item) => ({ raw: item, normalized: normalizeConcept(item) }))
.filter((item) => item.normalized && !STOPWORDS.has(item.normalized));
const counts = new Map();
for (const token of tokens) {
counts.set(token.normalized, (counts.get(token.normalized) || 0) + 1);
}
return [...counts.entries()]
.map(([normalizedName, count]) => ({
normalizedName,
name: tokens.find((item) => item.normalized === normalizedName)?.raw || normalizedName,
count
}))
.sort((a, b) => b.count - a.count || a.normalizedName.localeCompare(b.normalizedName))
.slice(0, 12);
}
function splitParagraphs(text) {
return text
.split(/\n\s*\n+/)
.map((part) => part.trim())
.filter(Boolean);
}
function upsertDocument(db, doc) {
const existingIndex = db.documents.findIndex((item) => item.id === doc.id);
if (existingIndex >= 0) db.documents[existingIndex] = doc;
else db.documents.push(doc);
}
function buildForDocument(db, document) {
const fragmentIds = db.fragments.filter((f) => f.documentId === document.id).map((f) => f.id);
db.fragments = db.fragments.filter((f) => f.documentId !== document.id);
db.links = db.links.filter((l) => !fragmentIds.includes(l.fromId) && !fragmentIds.includes(l.toId) && l.documentId !== document.id);
const usedConceptIds = new Set();
for (const link of db.links) {
if (link.toType === 'concept') usedConceptIds.add(link.toId);
if (link.fromType === 'concept') usedConceptIds.add(link.fromId);
}
db.concepts = db.concepts.filter((c) => usedConceptIds.has(c.id));
const fragments = splitParagraphs(document.text).map((text, index) => ({
id: stableId('frag', `document.id:index:text.slice(0, 80)`),
type: 'fragment',
documentId: document.id,
index,
text,
summary: text.slice(0, 100),
conceptNames: []
}));
const conceptMap = new Map(db.concepts.map((c) => [c.normalizedName, c]));
const links = [];
for (const fragment of fragments) {
const fragmentConcepts = extractConcepts(fragment.text).slice(0, 6);
fragment.conceptNames = fragmentConcepts.map((item) => item.normalizedName);
for (const concept of fragmentConcepts) {
if (!conceptMap.has(concept.normalizedName)) {
conceptMap.set(concept.normalizedName, {
id: stableId('concept', concept.normalizedName),
type: 'concept',
name: concept.name,
normalizedName: concept.normalizedName,
salience: Math.min(1, concept.count / 3)
});
}
const conceptNode = conceptMap.get(concept.normalizedName);
links.push({
id: stableId('link', `fragment.id->conceptNode.id`),
type: 'mentions',
fromId: fragment.id,
fromType: 'fragment',
toId: conceptNode.id,
toType: 'concept',
documentId: document.id,
score: concept.count
});
}
}
for (let i = 0; i < fragments.length - 1; i += 1) {
links.push({
id: stableId('link', `fragments[i].id->fragments[i + 1].id`),
type: 'adjacent',
fromId: fragments[i].id,
fromType: 'fragment',
toId: fragments[i + 1].id,
toType: 'fragment',
documentId: document.id,
score: 0.4
});
}
db.fragments.push(...fragments);
db.links.push(...links);
db.concepts = [...conceptMap.values()].sort((a, b) => a.normalizedName.localeCompare(b.normalizedName));
return { fragmentsCreated: fragments.length, linksCreated: links.length, conceptsTotal: db.concepts.length };
}
async function queryWithEmbedding(adapter, embeddingProvider, q, options = {}) {
const db = await adapter.load();
const query = normalizeConcept(q || '');
if (!query) throw new Error('Query is required');
const terms = query.split(' ').filter(Boolean);
let scored = db.fragments.map((fragment) => {
const textNorm = normalizeConcept(fragment.text);
let score = 0;
for (const term of terms) {
if (textNorm.includes(term)) score += 3;
if (fragment.conceptNames.includes(term)) score += 5;
}
return { fragment, score };
}).filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.fragment.index - b.fragment.index)
.slice(0, Number(options.limit || 3));
// Vector re-rank if real embedding provider (not KeywordEmbeddingProvider)
if (embeddingProvider && embeddingProvider.constructor.name !== 'KeywordEmbeddingProvider') {
try {
const queryVec = await embeddingProvider.embed(q);
const fragTexts = scored.length > 0 ? scored.map((s) => s.fragment.text) : db.fragments.slice(0, 20).map((f) => f.text);
const fragVecs = await embeddingProvider.embedBatch(fragTexts);
if (fragVecs.length > 0) {
const scoredWithVec = scored.length > 0
? scored.map((s, i) => ({ ...s, vecScore: embeddingProvider.similarity(queryVec, fragVecs[i]) }))
: db.fragments.slice(0, 20).map((f, i) => ({ fragment: f, score: 0, vecScore: embeddingProvider.similarity(queryVec, fragVecs[i]) }));
scoredWithVec.forEach((item) => {
item.blendedScore = item.score * 0.6 + item.vecScore * 10 * 0.4;
});
scored = scoredWithVec.sort((a, b) => b.blendedScore - a.blendedScore).slice(0, Number(options.limit || 3));
}
} catch {
// No API key or vector search failed, keep keyword results
}
}
const evidence = scored.map((item) => {
const doc = db.documents.find((d) => d.id === item.fragment.documentId);
return {
fragmentId: item.fragment.id,
documentId: item.fragment.documentId,
documentTitle: doc?.title || 'unknown',
score: item.score || item.vecScore,
text: item.fragment.text
};
});
const relatedConcepts = [...new Set(scored.flatMap((item) => item.fragment.conceptNames))]
.map((name) => db.concepts.find((c) => c.normalizedName === name))
.filter(Boolean)
.slice(0, 8)
.map((concept) => ({ id: concept.id, name: concept.name, normalizedName: concept.normalizedName }));
const answer = evidence.length
? `Found evidence.length relevant fragments for "q". Top evidence comes from [...new Set(evidence.map((item) => item.documentTitle))].join(', ').`
: `No strong match found for "q". Try a broader concept or ingest more documents.`;
return {
query: q,
answer,
evidence,
relatedConcepts,
stats: { documents: db.documents.length, fragments: db.fragments.length, concepts: db.concepts.length, links: db.links.length }
};
}
function parseArgs(argv) {
const result = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (!token.startsWith('--')) {
result._.push(token);
continue;
}
const [key, inline] = token.slice(2).split('=');
if (inline !== undefined) {
result[key] = inline;
continue;
}
const next = argv[i + 1];
if (!next || next.startsWith('--')) {
result[key] = true;
} else {
result[key] = next;
i += 1;
}
}
return result;
}
function printHelp() {
console.log(`LinkMind MVP CLI v0.2.0
Commands:
ingest --file <path> [--title <title>] [--sourceType <type>] [--storage json|sqlite]
query --q <text> [--limit <n>] [--embedding keyword|openai]
status [--storage json|sqlite]
reset [--storage json|sqlite]
help
Options:
--storage <adapter> Storage: json (default) or sqlite
--embedding <provider> Embedding: keyword (default) or openai
--db-path <path> Custom db path
Examples:
node dist/index.js ingest --file examples/sample-note.md --title "Sample"
node dist/index.js query --q "knowledge connector"
node dist/index.js status --storage sqlite
node dist/index.js reset --storage sqlite`);
}
async function main() {
const [, , command, ...rest] = process.argv;
const args = parseArgs(rest);
const storageType = args.storage || 'json';
const embeddingType = args.embedding || 'keyword';
const dbPathArg = args['db-path'] || undefined;
let adapter = null;
try {
if (!command || command === 'help' || args.help) {
printHelp();
return;
}
try {
const adaptersDir = IS_DIST ? 'storage-adapters' : 'src/storage-adapters';
if (storageType === 'sqlite') {
const { SqliteStorageAdapter } = require(path.join(THIS_DIR, adaptersDir, 'SqliteStorageAdapter.js'));
adapter = new SqliteStorageAdapter(dbPathArg ? { dbPath: dbPathArg } : {});
} else {
const { JsonStorageAdapter } = require(path.join(THIS_DIR, adaptersDir, 'JsonStorageAdapter.js'));
adapter = new JsonStorageAdapter(dbPathArg ? { dbPath: dbPathArg } : {});
}
} catch (e) {
if (e.message.includes('sqlite3 not installed')) {
console.error('[LinkMind] sqlite3 not installed. Run: npm install sqlite3');
} else {
console.error(`[LinkMind] Adapter load error: e.message`);
}
process.exitCode = 1;
return;
}
let embeddingProvider = null;
try {
const providersDir = IS_DIST ? 'embedding-providers' : 'src/embedding-providers';
const { MockProvider, OpenAICompatibleProvider } = require(path.join(THIS_DIR, providersDir, 'index.js'));
embeddingProvider = embeddingType === 'openai'
? new OpenAICompatibleProvider({ baseURL: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', apiKey: process.env.OPENAI_API_KEY || '', model: process.env.OPENAI_MODEL || 'text-embedding-3-small' })
: new MockProvider();
} catch {
embeddingProvider = null;
}
const now = () => new Date().toISOString();
if (command === 'ingest') {
if (!args.file) throw new Error('--file is required for ingest');
const full = path.resolve(process.cwd(), args.file);
if (!fs.existsSync(full)) throw new Error(`File not found: full`);
const text = fs.readFileSync(full, 'utf8');
const db = await adapter.load();
const doc = {
id: stableId('doc', `full:args.title || path.basename(full)`),
type: 'document',
title: args.title || path.basename(full),
sourceType: args.sourceType || 'file',
sourceUri: full,
importedAt: now(),
status: 'active',
text
};
upsertDocument(db, doc);
const stats = buildForDocument(db, doc);
await adapter.save(db);
console.log(JSON.stringify({ documentId: doc.id, title: doc.title, ...stats }, null, 2));
return;
}
if (command === 'query') {
if (!args.q && !args.query) throw new Error('--q is required for query');
const result = await queryWithEmbedding(adapter, embeddingProvider, args.q || args.query, { limit: args.limit });
console.log(JSON.stringify(result, null, 2));
return;
}
if (command === 'status') {
const db = await adapter.load();
console.log(JSON.stringify({
documents: db.documents.length,
fragments: db.fragments.length,
concepts: db.concepts.length,
links: db.links.length,
updatedAt: db.updatedAt,
adapter: storageType,
embedding: embeddingType
}, null, 2));
return;
}
if (command === 'reset') {
await adapter.clear();
console.log(JSON.stringify({ ok: true, adapter: storageType }, null, 2));
return;
}
throw new Error(`Unknown command: command`);
} catch (error) {
console.error(`[LinkMind] error.message`);
process.exitCode = 1;
} finally {
if (adapter) await adapter.close();
}
}
if (require.main === module) {
main();
}
module.exports = {
buildForDocument,
extractConcepts,
splitParagraphs,
normalizeConcept,
stableId,
queryWithEmbedding,
createEmptyDb
};
FILE:src/providers/base.js
/**
* EmbeddingProvider 接口契约
* 所有 provider 必须实现以下方法:
* embed(text) -> Promise<number[]> 单文本向量
* embedBatch(texts) -> Promise<number[][]> 批量向量
* similarity(a, b) -> number 余弦相似度
* dimensions() -> number 向量维度
*/
class EmbeddingProvider {
async embed(text) {
throw new Error('Not implemented: embed(text)');
}
async embedBatch(texts) {
throw new Error('Not implemented: embedBatch(texts)');
}
similarity(a, b) {
if (a.length !== b.length) throw new Error('Vector dimensions mismatch');
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB) + 1e-10);
}
dimensions() {
throw new Error('Not implemented: dimensions()');
}
}
module.exports = { EmbeddingProvider };
FILE:src/providers/openai.js
/**
* KeywordEmbeddingProvider
* MVP 版本:基于词项匹配的伪嵌入,用于无外部 API 依赖场景。
* 后续可替换为真正的 OpenAI/text-embedding-3* provider。
*/
const { EmbeddingProvider } = require('./base');
class KeywordEmbeddingProvider extends EmbeddingProvider {
constructor(opts = {}) {
super();
this._dims = opts.dimensions || 384; // 与 text-embedding-3-small 常用维度兼容
}
dimensions() {
return this._dims;
}
/**
* 简单词袋伪嵌入:基于 term frequency 构建稀疏向量
* 兼容性接口,实际向量召回走 retriever 的关键词逻辑
*/
async embed(text) {
const terms = (text || '')
.toLowerCase()
.replace(/[^\w\s\u4e00-\u9fa5]/g, ' ')
.split(/\s+/)
.filter(Boolean);
// 生成确定性伪向量(基于 term hash)
const vec = new Array(this._dims).fill(0);
for (const term of terms) {
const hash = this._hash(term);
for (let i = 0; i < terms.length; i++) {
const idx = (hash + i * 31) % this._dims;
vec[idx] += 1;
}
}
// L2 normalize
const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0));
return norm > 0 ? vec.map((v) => v / norm) : vec;
}
async embedBatch(texts) {
return Promise.all(texts.map((t) => this.embed(t)));
}
_hash(s) {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
}
return Math.abs(h);
}
}
/**
* OpenAiEmbeddingProvider
* OpenAI-compatible API 调用桩,后续填入真实 API key 和 endpoint 即可启用。
*/
class OpenAiEmbeddingProvider extends EmbeddingProvider {
constructor(opts = {}) {
super();
this.apiKey = opts.apiKey || process.env.OPENAI_API_KEY;
this.endpoint = opts.endpoint || 'https://api.openai.com/v1/embeddings';
this.model = opts.model || 'text-embedding-3-small';
this.dims = opts.dimensions || 1536;
}
dimensions() {
return this.dims;
}
async embed(text) {
if (!this.apiKey) {
throw new Error('OPENAI_API_KEY not set. Use --embedding=keyword or set OPENAI_API_KEY.');
}
const res = await fetch(this.endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer this.apiKey`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: this.model,
input: text,
dimensions: this.dims
})
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenAI embedding error res.status: err`);
}
const json = await res.json();
return json.data[0].embedding;
}
async embedBatch(texts) {
if (!this.apiKey) {
throw new Error('OPENAI_API_KEY not set. Use --embedding=keyword or set OPENAI_API_KEY.');
}
const res = await fetch(this.endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer this.apiKey`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: this.model,
input: texts,
dimensions: this.dims
})
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenAI embedding error res.status: err`);
}
const json = await res.json();
return json.data.map((item) => item.embedding);
}
}
module.exports = { KeywordEmbeddingProvider, OpenAiEmbeddingProvider };
FILE:src/retriever.js
/**
* retriever.js
* 关键词召回 + 向量相似度召回双层检索。
* ranker:余弦相似度
* 最终结果 = 关键词召回 ∪ 向量召回 → 去重排序
*/
const { normalizeConcept, STOPWORDS } = require('./utils/nlp');
/**
* @typedef {Object} RetrievalResult
* @property {string} fragmentId
* @property {string} documentId
* @property {string} documentTitle
* @property {number} score
* @property {string} text
* @property {'keyword'|'vector'|'hybrid'} source
*/
/**
* 计算余弦相似度
* @param {number[]} a
* @param {number[]} b
*/
function cosineSimilarity(a, b) {
if (a.length !== b.length) return 0;
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i += 1) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
const denom = Math.sqrt(na) * Math.sqrt(nb);
return denom === 0 ? 0 : dot / denom;
}
/**
* 关键词召回(从 fragment 列表)
*/
function keywordSearch(fragments, query, limit = 20) {
const terms = normalizeConcept(query)
.split(' ')
.filter(Boolean)
.filter((t) => !STOPWORDS.has(t));
if (terms.length === 0) return [];
return fragments
.map((frag) => {
const textNorm = normalizeConcept(frag.text);
let score = 0;
for (const term of terms) {
if (textNorm.includes(term)) score += 3;
if ((frag.conceptNames || []).includes(term)) score += 5;
}
return { frag, score };
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.frag.index - b.frag.index)
.slice(0, limit)
.map((item) => ({
fragmentId: item.frag.id,
documentId: item.frag.documentId,
documentTitle: item.frag.documentTitle || 'unknown',
score: item.score,
text: item.frag.text,
source: 'keyword'
}));
}
/**
* 向量相似度召回
* @param {object[]} fragments - 带 documentTitle
* @param {string} query
* @param {import('./embedding-providers/EmbeddingProvider')} embeddingProvider
* @param {number} limit
*/
async function vectorSearch(fragments, query, embeddingProvider, limit = 20) {
if (!embeddingProvider || fragments.length === 0) return [];
const texts = fragments.map((f) => f.text);
const [queryVec, fragVecs] = await Promise.all([
embeddingProvider.embed([query]),
embeddingProvider.embed(texts)
]);
const qv = queryVec[0];
return fragments
.map((frag, i) => ({
frag,
sim: cosineSimilarity(qv, fragVecs[i] || [])
}))
.filter((item) => item.sim > 0)
.sort((a, b) => b.sim - a.sim)
.slice(0, limit)
.map((item) => ({
fragmentId: item.frag.id,
documentId: item.frag.documentId,
documentTitle: item.frag.documentTitle || 'unknown',
score: item.sim,
text: item.frag.text,
source: 'vector'
}));
}
/**
* 合并关键词召回 + 向量召回,去重并排序
* @param {RetrievalResult[]} keywordResults
* @param {RetrievalResult[]} vectorResults
* @param {number} limit
*/
function mergeResults(keywordResults, vectorResults, limit = 10) {
const seen = new Map();
for (const r of [...keywordResults, ...vectorResults]) {
if (!seen.has(r.fragmentId)) {
seen.set(r.fragmentId, { ...r });
} else {
// 取最高分
const existing = seen.get(r.fragmentId);
existing.score = Math.max(existing.score, r.score);
existing.source = existing.source === r.source ? existing.source : 'hybrid';
}
}
return [...seen.values()]
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
/**
* 主检索入口
* @param {object} options
* @param {object[]} options.fragments - fragment 列表,需含 documentTitle
* @param {string} options.query
* @param {import('./embedding-providers/EmbeddingProvider')} [options.embeddingProvider]
* @param {number} [options.limit]
*/
async function retrieve({ fragments, query, embeddingProvider = null, limit = 10 }) {
const kw = keywordSearch(fragments, query, limit * 2);
const vec = embeddingProvider
? await vectorSearch(fragments, query, embeddingProvider, limit * 2)
: [];
return mergeResults(kw, vec, limit);
}
module.exports = {
keywordSearch,
vectorSearch,
mergeResults,
cosineSimilarity,
retrieve
};
FILE:src/storage-adapters/JsonStorageAdapter.js
/**
* JsonStorageAdapter
* 基于本地 JSON 文件的存储实现,保持向后兼容。
*/
const fs = require('fs');
const path = require('path');
const { StorageAdapter } = require('./StorageAdapter');
const ROOT = path.resolve(__dirname, '../..');
const DATA_DIR = path.join(ROOT, 'data');
const DB_PATH = path.join(DATA_DIR, 'workspace.json');
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function now() {
return new Date().toISOString();
}
class JsonStorageAdapter extends StorageAdapter {
constructor() {
super();
this._db = null;
}
_loadDb() {
ensureDir(DATA_DIR);
if (!fs.existsSync(DB_PATH)) {
const empty = this._emptyDb();
this._saveDb(empty);
return empty;
}
return JSON.parse(fs.readFileSync(DB_PATH, 'utf8'));
}
_saveDb(db) {
db.updatedAt = now();
ensureDir(DATA_DIR);
fs.writeFileSync(DB_PATH, JSON.stringify(db, null, 2), 'utf8');
}
_emptyDb() {
return { version: 1, createdAt: now(), updatedAt: now(), documents: [], fragments: [], concepts: [], links: [] };
}
async init() {
this._db = this._loadDb();
}
// --- Document ---
async saveDocument(doc) {
const db = this._loadDb();
const idx = db.documents.findIndex((d) => d.id === doc.id);
if (idx >= 0) db.documents[idx] = doc;
else db.documents.push(doc);
this._saveDb(db);
return doc;
}
async getDocument(id) {
const db = this._loadDb();
return db.documents.find((d) => d.id === id) || null;
}
async listDocuments() {
const db = this._loadDb();
return db.documents;
}
async deleteDocument(id) {
const db = this._loadDb();
db.documents = db.documents.filter((d) => d.id !== id);
this._saveDb(db);
}
// --- Fragment ---
async saveFragment(frag) {
const db = this._loadDb();
const idx = db.fragments.findIndex((f) => f.id === frag.id);
if (idx >= 0) db.fragments[idx] = frag;
else db.fragments.push(frag);
this._saveDb(db);
return frag;
}
async getFragment(id) {
const db = this._loadDb();
return db.fragments.find((f) => f.id === id) || null;
}
async listFragments(documentId) {
const db = this._loadDb();
return db.fragments.filter((f) => f.documentId === documentId);
}
async deleteFragmentsByDocument(documentId) {
const db = this._loadDb();
db.fragments = db.fragments.filter((f) => f.documentId !== documentId);
this._saveDb(db);
}
async saveFragments(fragments) {
const db = this._loadDb();
for (const frag of fragments) {
const idx = db.fragments.findIndex((f) => f.id === frag.id);
if (idx >= 0) db.fragments[idx] = frag;
else db.fragments.push(frag);
}
this._saveDb(db);
}
// --- Concept ---
async saveConcept(concept) {
const db = this._loadDb();
const idx = db.concepts.findIndex((c) => c.id === concept.id);
if (idx >= 0) db.concepts[idx] = concept;
else db.concepts.push(concept);
this._saveDb(db);
return concept;
}
async getConcept(id) {
const db = this._loadDb();
return db.concepts.find((c) => c.id === id) || null;
}
async listConcepts() {
const db = this._loadDb();
return db.concepts;
}
async upsertConcept(concept) {
const db = this._loadDb();
const idx = db.concepts.findIndex((c) => c.id === concept.id);
if (idx >= 0) db.concepts[idx] = concept;
else db.concepts.push(concept);
this._saveDb(db);
return concept;
}
async saveConcepts(concepts) {
const db = this._loadDb();
for (const c of concepts) {
const idx = db.concepts.findIndex((x) => x.id === c.id);
if (idx >= 0) db.concepts[idx] = c;
else db.concepts.push(c);
}
this._saveDb(db);
}
// --- Link ---
async saveLink(link) {
const db = this._loadDb();
const idx = db.links.findIndex((l) => l.id === link.id);
if (idx >= 0) db.links[idx] = link;
else db.links.push(link);
this._saveDb(db);
return link;
}
async getLinks(fromId, toId) {
const db = this._loadDb();
return db.links.filter(
(l) => (fromId ? l.fromId === fromId : true) && (toId ? l.toId === toId : true)
);
}
async deleteLinksByDocument(documentId) {
const db = this._loadDb();
db.links = db.links.filter((l) => l.documentId !== documentId);
this._saveDb(db);
}
async saveLinks(links) {
const db = this._loadDb();
for (const l of links) {
const idx = db.links.findIndex((x) => x.id === l.id);
if (idx >= 0) db.links[idx] = l;
else db.links.push(l);
}
this._saveDb(db);
}
// --- Full DB operations (for CLI compat) ---
async load() {
return this._loadDb();
}
async save(db) {
this._saveDb(db);
}
async close() {
// no-op for JSON
}
// --- Workspace ---
async clear() {
this._saveDb(this._emptyDb());
}
// --- Stats ---
getStats() {
const db = this._loadDb();
return {
documents: db.documents.length,
fragments: db.fragments.length,
concepts: db.concepts.length,
links: db.links.length,
updatedAt: db.updatedAt,
dbPath: DB_PATH
};
}
}
module.exports = { JsonStorageAdapter };
FILE:src/storage-adapters/SqliteStorageAdapter.js
/**
* SqliteStorageAdapter
* 基于 better-sqlite3 的 SQLite 存储实现。
* 建表语句 + 所有 StorageAdapter 方法的完整实现。
*/
const { StorageAdapter } = require('./StorageAdapter');
let sqlite3 = null;
try {
sqlite3 = require('better-sqlite3');
} catch {
// better-sqlite3 not installed — adapter will throw on init()
}
const ROOT = path => require('path').resolve(__dirname, '../..');
const DATA_DIR = path.join(ROOT, 'data');
const DB_PATH = path.join(DATA_DIR, 'db.sqlite');
function ensureDir(dir) {
require('fs').mkdirSync(dir, { recursive: true });
}
function now() {
return new Date().toISOString();
}
const SCHEMA = `
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'document',
title TEXT,
sourceType TEXT,
sourceUri TEXT,
importedAt TEXT,
status TEXT DEFAULT 'active',
text TEXT,
createdAt TEXT,
updatedAt TEXT
);
CREATE TABLE IF NOT EXISTS fragments (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'fragment',
documentId TEXT,
"index" INTEGER,
text TEXT,
summary TEXT,
conceptNames TEXT,
createdAt TEXT,
FOREIGN KEY (documentId) REFERENCES documents(id)
);
CREATE TABLE IF NOT EXISTS concepts (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'concept',
name TEXT,
normalizedName TEXT UNIQUE,
salience REAL,
createdAt TEXT
);
CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY,
type TEXT,
fromId TEXT,
fromType TEXT,
toId TEXT,
toType TEXT,
documentId TEXT,
score REAL,
createdAt TEXT,
FOREIGN KEY (documentId) REFERENCES documents(id)
);
CREATE TABLE IF NOT EXISTS fragment_vectors (
fragmentId TEXT PRIMARY KEY,
vector BLOB,
updatedAt TEXT,
FOREIGN KEY (fragmentId) REFERENCES fragments(id)
);
CREATE INDEX IF NOT EXISTS idx_fragments_documentId ON fragments(documentId);
CREATE INDEX IF NOT EXISTS idx_links_fromId ON links(fromId);
CREATE INDEX IF NOT EXISTS idx_links_toId ON links(toId);
CREATE INDEX IF NOT EXISTS idx_links_documentId ON links(documentId);
CREATE INDEX IF NOT EXISTS idx_concepts_normalizedName ON concepts(normalizedName);
`;
class SqliteStorageAdapter extends StorageAdapter {
constructor({ dbPath } = {}) {
super();
this._dbPath = dbPath || DB_PATH;
this._db = null;
}
async init() {
if (!sqlite3) {
throw new Error('better-sqlite3 is not installed. Run: npm install better-sqlite3');
}
ensureDir(require('path').dirname(this._dbPath));
this._db = sqlite3(this._dbPath);
this._db.pragma('journal_mode = WAL');
this._db.exec(SCHEMA);
}
_run(sql, params = []) {
try {
return this._db.prepare(sql).run(...params);
} catch (e) {
throw new Error(`SQLite run error: e.message | sql: sql`);
}
}
_all(sql, params = []) {
try {
return this._db.prepare(sql).all(...params);
} catch (e) {
throw new Error(`SQLite all error: e.message | sql: sql`);
}
}
_get(sql, params = []) {
try {
return this._db.prepare(sql).get(...params);
} catch (e) {
throw new Error(`SQLite get error: e.message | sql: sql`);
}
}
// --- Document ---
async saveDocument(doc) {
this._run(
`INSERT OR REPLACE INTO documents (id,type,title,sourceType,sourceUri,importedAt,status,text,createdAt,updatedAt)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
[doc.id, doc.type || 'document', doc.title || '', doc.sourceType || '', doc.sourceUri || '',
doc.importedAt || now(), doc.status || 'active', doc.text || '',
doc.createdAt || now(), now()]
);
return doc;
}
async getDocument(id) {
const row = this._get('SELECT * FROM documents WHERE id = ?', [id]);
return row ? this._mapDoc(row) : null;
}
async listDocuments() {
return this._all('SELECT * FROM documents ORDER BY importedAt DESC').map(this._mapDoc);
}
async deleteDocument(id) {
this._run('DELETE FROM documents WHERE id = ?', [id]);
}
// --- Fragment ---
async saveFragment(frag) {
this._run(
`INSERT OR REPLACE INTO fragments (id,type,documentId,"index",text,summary,conceptNames,createdAt)
VALUES (?,?,?,?,?,?,?,?)`,
[frag.id, 'fragment', frag.documentId, frag.index, frag.text, frag.summary || frag.text.slice(0, 100),
JSON.stringify(frag.conceptNames || []), frag.createdAt || now()]
);
return frag;
}
async getFragment(id) {
const row = this._get('SELECT * FROM fragments WHERE id = ?', [id]);
return row ? this._mapFrag(row) : null;
}
async listFragments(documentId) {
return this._all('SELECT * FROM fragments WHERE documentId = ? ORDER BY "index" ASC', [documentId])
.map(this._mapFrag);
}
async deleteFragmentsByDocument(documentId) {
this._run('DELETE FROM fragments WHERE documentId = ?', [documentId]);
}
async saveFragments(fragments) {
const stmt = this._db.prepare(
`INSERT OR REPLACE INTO fragments (id,type,documentId,"index",text,summary,conceptNames,createdAt)
VALUES (?,?,?,?,?,?,?,?)`
);
const insertMany = this._db.transaction((items) => {
for (const frag of items) {
stmt.run(frag.id, 'fragment', frag.documentId, frag.index, frag.text,
frag.summary || frag.text.slice(0, 100), JSON.stringify(frag.conceptNames || []),
frag.createdAt || now());
}
});
insertMany(fragments);
return fragments;
}
// --- Concept ---
async saveConcept(concept) {
this._run(
`INSERT OR REPLACE INTO concepts (id,type,name,normalizedName,salience,createdAt)
VALUES (?,?,?,?,?,?)`,
[concept.id, 'concept', concept.name, concept.normalizedName, concept.salience || 0,
concept.createdAt || now()]
);
return concept;
}
async getConcept(id) {
const row = this._get('SELECT * FROM concepts WHERE id = ?', [id]);
return row ? this._mapConcept(row) : null;
}
async listConcepts() {
return this._all('SELECT * FROM concepts ORDER BY normalizedName ASC').map(this._mapConcept);
}
async upsertConcept(concept) {
return this.saveConcept(concept);
}
async saveConcepts(concepts) {
const stmt = this._db.prepare(
`INSERT OR REPLACE INTO concepts (id,type,name,normalizedName,salience,createdAt) VALUES (?,?,?,?,?,?)`
);
const insertMany = this._db.transaction((items) => {
for (const c of items) {
stmt.run(c.id, 'concept', c.name, c.normalizedName, c.salience || 0, c.createdAt || now());
}
});
insertMany(concepts);
return concepts;
}
// --- Link ---
async saveLink(link) {
this._run(
`INSERT OR REPLACE INTO links (id,type,fromId,fromType,toId,toType,documentId,score,createdAt)
VALUES (?,?,?,?,?,?,?,?,?)`,
[link.id, link.type, link.fromId, link.fromType, link.toId, link.toType,
link.documentId, link.score || 0, link.createdAt || now()]
);
return link;
}
async getLinks(fromId, toId) {
let sql = 'SELECT * FROM links WHERE 1=1';
const params = [];
if (fromId) { sql += ' AND fromId = ?'; params.push(fromId); }
if (toId) { sql += ' AND toId = ?'; params.push(toId); }
return this._all(sql, params);
}
async deleteLinksByDocument(documentId) {
this._run('DELETE FROM links WHERE documentId = ?', [documentId]);
}
async saveLinks(links) {
const stmt = this._db.prepare(
`INSERT OR REPLACE INTO links (id,type,fromId,fromType,toId,toType,documentId,score,createdAt)
VALUES (?,?,?,?,?,?,?,?,?)`
);
const insertMany = this._db.transaction((items) => {
for (const l of items) {
stmt.run(l.id, l.type, l.fromId, l.fromType, l.toId, l.toType,
l.documentId, l.score || 0, l.createdAt || now());
}
});
insertMany(links);
return links;
}
// --- Vector ---
async saveFragmentVector(fragmentId, vector) {
const buf = Buffer.from(JSON.stringify(vector));
this._run(
`INSERT OR REPLACE INTO fragment_vectors (fragmentId,vector,updatedAt) VALUES (?,?,?)`,
[fragmentId, buf, now()]
);
}
async getFragmentVector(fragmentId) {
const row = this._get('SELECT vector FROM fragment_vectors WHERE fragmentId = ?', [fragmentId]);
if (!row) return null;
return JSON.parse(row.vector);
}
async listFragmentVectors() {
return this._all('SELECT fragmentId, vector FROM fragment_vectors').map((r) => ({
fragmentId: r.fragmentId,
vector: JSON.parse(r.vector)
}));
}
// --- Full DB operations (for CLI compat) ---
async load() {
const docs = this._all('SELECT * FROM documents ORDER BY importedAt DESC').map(this._mapDoc);
const frags = this._all('SELECT * FROM fragments ORDER BY "index" ASC').map(this._mapFrag);
const concepts = this._all('SELECT * FROM concepts ORDER BY normalizedName ASC').map(this._mapConcept);
const links = this._all('SELECT * FROM links');
const updatedAt = this._get('SELECT MAX(updatedAt) as t FROM documents UNION ALL SELECT MAX(updatedAt) as t FROM fragments')?.t || new Date().toISOString();
return { version: 1, documents: docs, fragments: frags, concepts, links, updatedAt };
}
async save(db) {
await this.clear();
if (db.documents) for (const d of db.documents) await this.saveDocument(d);
if (db.fragments) for (const f of db.fragments) await this.saveFragment(f);
if (db.concepts) for (const c of db.concepts) await this.saveConcept(c);
if (db.links) for (const l of db.links) await this.saveLink(l);
}
async close() {
if (this._db) { this._db.close(); this._db = null; }
}
// --- Workspace ---
async clear() {
this._run('DELETE FROM fragment_vectors');
this._run('DELETE FROM links');
this._run('DELETE FROM fragments');
this._run('DELETE FROM concepts');
this._run('DELETE FROM documents');
}
getStats() {
return {
documents: this._get('SELECT COUNT(*) as n FROM documents')?.n || 0,
fragments: this._get('SELECT COUNT(*) as n FROM fragments')?.n || 0,
concepts: this._get('SELECT COUNT(*) as n FROM concepts')?.n || 0,
links: this._get('SELECT COUNT(*) as n FROM links')?.n || 0,
dbPath: this._dbPath
};
}
// --- Mappers ---
_mapDoc(row) {
return { ...row };
}
_mapFrag(row) {
return { ...row, conceptNames: JSON.parse(row.conceptNames || '[]') };
}
_mapConcept(row) {
return { ...row };
}
}
module.exports = { SqliteStorageAdapter };
FILE:src/storage-adapters/StorageAdapter.js
/**
* StorageAdapter Interface
* 所有 storage adapter 必须实现此接口契约。
*/
class StorageAdapter {
/**
* @returns {Promise<void>}
*/
async init() {
throw new Error('Not implemented');
}
// --- Document ---
async saveDocument(doc) {
throw new Error('Not implemented');
}
async getDocument(id) {
throw new Error('Not implemented');
}
async listDocuments() {
throw new Error('Not implemented');
}
async deleteDocument(id) {
throw new Error('Not implemented');
}
// --- Fragment ---
async saveFragment(frag) {
throw new Error('Not implemented');
}
async getFragment(id) {
throw new Error('Not implemented');
}
async listFragments(documentId) {
throw new Error('Not implemented');
}
async deleteFragmentsByDocument(documentId) {
throw new Error('Not implemented');
}
// --- Concept ---
async saveConcept(concept) {
throw new Error('Not implemented');
}
async getConcept(id) {
throw new Error('Not implemented');
}
async listConcepts() {
throw new Error('Not implemented');
}
async upsertConcept(concept) {
throw new Error('Not implemented');
}
// --- Link ---
async saveLink(link) {
throw new Error('Not implemented');
}
async getLinks(fromId, toId) {
throw new Error('Not implemented');
}
async deleteLinksByDocument(documentId) {
throw new Error('Not implemented');
}
// --- Bulk ---
async saveFragments(fragments) {
throw new Error('Not implemented');
}
async saveConcepts(concepts) {
throw new Error('Not implemented');
}
async saveLinks(links) {
throw new Error('Not implemented');
}
// --- Workspace ---
async clear() {
throw new Error('Not implemented');
}
}
module.exports = { StorageAdapter };
FILE:src/storage-adapters/index.js
/**
* storage-adapters/index.js
* Factory: 根据配置返回对应的 storage adapter 实例。
*/
const { JsonStorageAdapter } = require('./JsonStorageAdapter');
const { SqliteStorageAdapter } = require('./SqliteStorageAdapter');
function createStorageAdapter(type = 'json', options = {}) {
switch (type) {
case 'json':
return new JsonStorageAdapter(options);
case 'sqlite':
return new SqliteStorageAdapter(options);
default:
throw new Error(`Unknown storage adapter type: type. Use 'json' or 'sqlite'.`);
}
}
module.exports = { createStorageAdapter, JsonStorageAdapter, SqliteStorageAdapter };
FILE:src/utils/nlp.js
/**
* nlp.js - 文本规范化工具
*/
const STOPWORDS = new Set([
'the','and','for','that','this','with','from','into','your','have','will','not','are','was','were','can','could','should','about','what','when','where','which','while','than','then','them','they','their','there','here','also','more','most','some','such','using','used','use','make','made','over','under','very','just','only','each','been','being','does','did','done','how','why','our','you','its','his','her','she','him','who','has','had','but','too','via','per','one','two','three',
'我们','你们','他们','这个','那个','一个','一种','可以','需要','如果','因为','所以','以及','进行','通过','没有','不是','就是','如何','什么','为什么','时候','今天','已经','还有','对于','一些','这种','那些','并且','或者','主要','用户','系统','功能','能力','作为','实现','相关','用于'
]);
function normalizeConcept(text) {
if (!text) return '';
return text
.toLowerCase()
.replace(/[\u2018\u2019'"""''()()【】\[\],.:;!?/\\]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
module.exports = { normalizeConcept, STOPWORDS };
FILE:tests/smoke-test.js
#!/usr/bin/env node
/**
* LinkMind Smoke Test - Phase 2
* 覆盖: storage adapters + embedding providers + retriever
*/
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import { pathToFileURL } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SKILL_ROOT = path.resolve(__dirname, '..');
process.chdir(SKILL_ROOT);
function run(cmd) {
console.log(`\n$ cmd`);
return JSON.parse(execSync(cmd, { encoding: 'utf8' }));
}
function check(label, cond) {
if (cond) {
console.log(` ✓ label`);
} else {
console.error(` ✗ FAIL: label`);
process.exitCode = 1;
}
}
// Import phase-2 modules via dynamic import
const srcDir = path.join(SKILL_ROOT, 'src');
async function runTests() {
console.log('=== LinkMind Smoke Test (Phase 2) ===\n');
// Phase 1: CLI commands
console.log('--- Phase 1: CLI Commands ---');
const reset = run('node dist/index.js reset');
check('reset returns ok', reset.ok === true);
const empty = run('node dist/index.js status');
check('empty workspace has 0 documents', empty.documents === 0);
const ingest = run('node dist/index.js ingest --file examples/sample-note.md --title "Sample Note"');
check('ingest returns documentId', !!ingest.documentId);
check('ingest creates fragments', ingest.fragmentsCreated >= 1);
const after = run('node dist/index.js status');
check('has 1 document', after.documents === 1);
check('has fragments', after.fragments >= 1);
const q1 = run('node dist/index.js query --q "knowledge"');
check('query "knowledge" finds evidence', q1.evidence && q1.evidence.length >= 1);
check('query "knowledge" returns answer', !!q1.answer);
const q2 = run('node dist/index.js query --q "xyznonexistentterm12345"');
check('query no-match returns empty evidence', q2.evidence && q2.evidence.length === 0);
const q3 = run('node dist/index.js query --q "knowledge" --limit 1');
check('query with limit respects limit', q3.evidence && q3.evidence.length <= 1);
// Phase 2: Storage Adapters
console.log('\n--- Phase 2: Storage Adapters ---');
const JsonStorageAdapterMod = await import(pathToFileURL(path.join(srcDir, 'storage-adapters/JsonStorageAdapter.js')).href);
const StorageAdapterMod = await import(pathToFileURL(path.join(srcDir, 'storage-adapters/StorageAdapter.js')).href);
const { JsonStorageAdapter } = JsonStorageAdapterMod;
const { StorageAdapter } = StorageAdapterMod;
const jsAdapter = new JsonStorageAdapter();
await jsAdapter.init();
await jsAdapter.clear();
check('JsonStorageAdapter.init() ok', true);
const testDoc = {
id: 'doc_test1',
type: 'document',
title: 'Test Doc',
sourceType: 'note',
sourceUri: '/test/path.md',
importedAt: new Date().toISOString(),
status: 'active',
text: 'This is a test document for storage adapter testing.'
};
await jsAdapter.saveDocument(testDoc);
const retrieved = await jsAdapter.getDocument('doc_test1');
check('JsonStorageAdapter.saveDocument + getDocument', retrieved && retrieved.title === 'Test Doc');
const docs = await jsAdapter.listDocuments();
check('JsonStorageAdapter.listDocuments', docs && docs.length >= 1);
const testFrag = {
id: 'frag_test1',
type: 'fragment',
documentId: 'doc_test1',
index: 0,
text: 'This is a test fragment.',
summary: 'This is a test',
conceptNames: ['test'],
createdAt: new Date().toISOString()
};
await jsAdapter.saveFragment(testFrag);
const frag = await jsAdapter.getFragment('frag_test1');
check('JsonStorageAdapter.saveFragment + getFragment', frag && frag.text.includes('test fragment'));
const frags = await jsAdapter.listFragments('doc_test1');
check('JsonStorageAdapter.listFragments', frags && frags.length >= 1);
const testConcept = {
id: 'concept_test1',
type: 'concept',
name: 'TestConcept',
normalizedName: 'testconcept',
salience: 0.5,
createdAt: new Date().toISOString()
};
await jsAdapter.saveConcept(testConcept);
const concepts = await jsAdapter.listConcepts();
check('JsonStorageAdapter.listConcepts', concepts && concepts.some(c => c.normalizedName === 'testconcept'));
const testLink = {
id: 'link_test1',
type: 'mentions',
fromId: 'frag_test1',
fromType: 'fragment',
toId: 'concept_test1',
toType: 'concept',
documentId: 'doc_test1',
score: 1,
createdAt: new Date().toISOString()
};
await jsAdapter.saveLink(testLink);
const links = await jsAdapter.getLinks('frag_test1', null);
check('JsonStorageAdapter.saveLink + getLinks', links && links.some(l => l.id === 'link_test1'));
// test bulk save
await jsAdapter.clear();
await jsAdapter.saveDocument(testDoc);
await jsAdapter.saveFragments([testFrag]);
await jsAdapter.saveConcepts([testConcept]);
await jsAdapter.saveLinks([testLink]);
const afterBulk = await jsAdapter.listFragments('doc_test1');
check('JsonStorageAdapter.saveFragments bulk', afterBulk && afterBulk.length >= 1);
await jsAdapter.clear();
const emptyAfterClear = await jsAdapter.listDocuments();
check('JsonStorageAdapter.clear()', emptyAfterClear.length === 0);
// StorageAdapter interface
check('StorageAdapter is a class', typeof StorageAdapter === 'function');
// Phase 2: Embedding Providers
console.log('\n--- Phase 2: Embedding Providers ---');
const MockProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/MockProvider.js')).href);
const OpenAICompatibleProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/OpenAICompatibleProvider.js')).href);
const EmbeddingProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/EmbeddingProvider.js')).href);
const { MockProvider } = MockProviderMod;
const { OpenAICompatibleProvider } = OpenAICompatibleProviderMod;
const { EmbeddingProvider } = EmbeddingProviderMod;
const mock = new MockProvider({ dimension: 128 });
check('MockProvider.dimension', mock.dimension === 128);
check('MockProvider.name', mock.name === 'mock');
const [vec] = await mock.embed(['hello world']);
check('MockProvider.embed returns vector', Array.isArray(vec) && vec.length === 128);
check('MockProvider.vector is L2-normalized', Math.abs(vec.reduce((s, v) => s + v * v, 0) - 1) < 0.01);
const [vec2] = await mock.embed(['hello world']);
check('MockProvider caches results', vec2 === vec);
const differentText = await mock.embed(['different text']);
check('MockProvider different text yields different vector', differentText[0] !== vec);
const openai = new OpenAICompatibleProvider({ baseURL: 'https://api.example.com/v1', apiKey: 'test-key', model: 'test-model', dimension: 256 });
check('OpenAICompatibleProvider.dimension', openai.dimension === 256);
check('OpenAICompatibleProvider.name includes model', openai.name.includes('test-model'));
check('OpenAICompatibleProvider.name includes baseURL hint', openai.name.includes('openai-compatible'));
check('EmbeddingProvider is a class', typeof EmbeddingProvider === 'function');
// Phase 2: Retriever
console.log('\n--- Phase 2: Retriever ---');
const retrieverMod = await import(pathToFileURL(path.join(srcDir, 'retriever.js')).href);
const { keywordSearch, vectorSearch, mergeResults, cosineSimilarity, retrieve } = retrieverMod;
check('cosineSimilarity identical vectors = 1', Math.abs(cosineSimilarity([0.5, 0.5, 0.5, 0.5], [0.5, 0.5, 0.5, 0.5]) - 1) < 0.0001);
check('cosineSimilarity orthogonal = 0', Math.abs(cosineSimilarity([1, 0, 0], [0, 1, 0])) < 0.0001);
const testFrags = [
{ id: 'f1', documentId: 'd1', documentTitle: 'Doc 1', index: 0, text: 'knowledge graph is useful', conceptNames: ['knowledge', 'graph'] },
{ id: 'f2', documentId: 'd1', documentTitle: 'Doc 1', index: 1, text: 'machine learning and AI', conceptNames: ['machine', 'learning'] },
{ id: 'f3', documentId: 'd2', documentTitle: 'Doc 2', index: 0, text: 'knowledge base systems', conceptNames: ['knowledge', 'base'] }
];
const kw = keywordSearch(testFrags, 'knowledge');
check('keywordSearch finds knowledge fragments', kw.length >= 2);
check('keywordSearch assigns score > 0', kw.every(r => r.score > 0));
check('keywordSearch source=keyword', kw.every(r => r.source === 'keyword'));
const vecResults = await vectorSearch(testFrags, 'knowledge graph', mock);
check('vectorSearch returns results', Array.isArray(vecResults));
check('vectorSearch source=vector', vecResults.every(r => r.source === 'vector'));
const merged = mergeResults(kw, vecResults, 5);
check('mergeResults deduplicates', merged.length <= kw.length + vecResults.length);
check('mergeResults returns array', Array.isArray(merged));
check('mergeResults items have score', merged.every(r => typeof r.score === 'number'));
const kwOnly = await retrieve({ fragments: testFrags, query: 'knowledge', limit: 5 });
check('retrieve keyword-only works', kwOnly.length >= 1);
const hybrid = await retrieve({ fragments: testFrags, query: 'knowledge', embeddingProvider: mock, limit: 5 });
check('retrieve hybrid works', hybrid.length >= 1);
check('retrieve hybrid may include vector source', hybrid.some(r => r.source === 'vector' || r.source === 'hybrid'));
console.log('\n=== Smoke Test Complete ===');
if (process.exitCode === 1) {
console.log('RESULT: FAILED');
process.exit(1);
} else {
console.log('RESULT: PASSED');
}
}
runTests().catch((err) => {
console.error('Test error:', err);
process.exit(1);
});
LinkMind 知识连接引擎 Phase 2 - 本地化知识中枢 CLI 工具,支持 storage adapter 抽象层和 OpenAI-compatible embedding provider。
---
name: linkmind
description: LinkMind 知识连接引擎 Phase 2 - 本地化知识中枢 CLI 工具,支持 storage adapter 抽象层和 OpenAI-compatible embedding provider。
---
# LinkMind 知识连接引擎 (Phase 2)
LinkMind 是一个本地化知识连接引擎,将异构内容沉淀为统一知识单元,建立可解释的节点连接网络,并支持带证据的查询回答。
## Phase 2 新增功能
- **Storage Adapter 抽象层**:`JsonStorageAdapter`(默认,保持兼容)+ `SqliteStorageAdapter`(基于 better-sqlite3)
- **Embedding Provider 抽象层**:`MockProvider`(离线测试)+ `OpenAICompatibleProvider`(接入 OpenAI/vLLM/Ollama)
- **向量相似度召回**:余弦相似度 ranker,与关键词召回结果合并去重
- **渐进升级**:Phase 1 JSON 方案完全兼容,无需迁移
## 核心功能
- **摄入 (ingest)**: 读取本地文本文件,切分为语义友好的段落片段,抽取概念节点,建立片段-概念、片段-片段邻接连接
- **查询 (query)**: 支持关键词/概念查询 + 向量相似度召回,返回答案摘要、证据片段列表、相关概念节点、统计概览
- **状态 (status)**: 查看当前工作空间统计信息
- **重置 (reset)**: 清空工作空间
## 架构模块
| 模块 | 职责 | 状态 |
|------|------|------|
| `storage-adapters/` | StorageAdapter 接口 + JSON/SQLite 双实现 | ✅ Phase 2 |
| `embedding-providers/` | EmbeddingProvider 接口 + Mock/OpenAI 双实现 | ✅ Phase 2 |
| `retriever` | 关键词召回 + 向量召回双层检索,余弦相似度 ranker | ✅ Phase 2 |
| `unit-builder` | 文档切分为 fragment,抽取 fragment.conceptNames | ✅ MVP |
| `link-builder` | fragment↔concept、fragment↔fragment 邻接连接 | ✅ MVP |
| `answer-composer` | 组装 answer + evidence + relatedConcepts | ✅ MVP |
| `ingest-normalizer` | 原始文件标准化为 Document | ✅ MVP |
| `guardrails` | 空查询、空结果边界处理 | ✅ MVP |
## 技术栈
- 零外部依赖(pure Node.js built-ins)
- 本地 JSON 文件存储(`data/workspace.json`)
- 可选 SQLite 存储(`better-sqlite3`)
- 数据模型: Document → Fragment → Concept + LinkEdge
## 安装与运行
```bash
cd skills/linkmind
# 构建
npm run build
# 运行 CLI
node dist/index.js --help
```
## 使用方法
### 摄入文档
```bash
node dist/index.js ingest --file examples/sample-note.md --title "我的笔记" --sourceType note
```
### 查询知识
```bash
node dist/index.js query --q "知识连接"
node dist/index.js query --q "knowledge" --limit 5
```
### 查看状态
```bash
node dist/index.js status
```
### 重置工作空间
```bash
node dist/index.js reset
```
## 存储适配器 (Storage Adapter)
### JsonStorageAdapter(默认)
- 数据存储在 `data/workspace.json`
- 完全向后兼容 Phase 1
- 零配置开箱即用
### SqliteStorageAdapter
- 需要安装:`npm install better-sqlite3`
- 数据存储在 `data/db.sqlite`
- 支持事务批量写入,性能更高
```js
import { createStorageAdapter } from './src/storage-adapters/index.js';
const adapter = createStorageAdapter('sqlite');
await adapter.init();
await adapter.saveDocument({ id: 'doc_1', title: 'Test', ... });
```
## Embedding Provider
### MockProvider(默认,离线可用)
```js
import { createEmbeddingProvider } from './src/embedding-providers/index.js';
const provider = createEmbeddingProvider('mock', { dimension: 1536 });
const [vec] = await provider.embed(['hello world']);
```
### OpenAICompatibleProvider
```js
const provider = createEmbeddingProvider('openai', {
baseURL: 'https://api.openai.com/v1', // 或 vLLM/Ollama 地址
apiKey: 'sk-xxx',
model: 'text-embedding-3-small',
dimension: 1536
});
const vectors = await provider.embed(['hello', 'world']);
```
## 检索流程 (Retriever)
查询时使用双层召回:
1. **关键词召回**(keyword):基于 `normalizeConcept` + concept 名称匹配
2. **向量召回**(vector):余弦相似度(可选,需配置 embedding provider)
3. **合并去重**:取最高分,结果标记 `source: 'keyword' | 'vector' | 'hybrid'`
```js
import { retrieve } from './src/retriever.js';
const results = await retrieve({
fragments,
query: 'knowledge graph',
embeddingProvider: mockProvider, // 传 null 则仅关键词召回
limit: 10
});
// results: [{ fragmentId, documentId, documentTitle, score, text, source }]
```
## 数据模型
### Document
```json
{
"id": "doc_xxxxx",
"type": "document",
"title": "我的笔记",
"sourceType": "note",
"sourceUri": "/path/to/file.md",
"importedAt": "2026-04-04T...",
"status": "active"
}
```
### Fragment
```json
{
"id": "frag_xxxxx",
"type": "fragment",
"documentId": "doc_xxxxx",
"index": 0,
"text": "LinkMind 是...",
"summary": "LinkMind 是...",
"conceptNames": ["linkmind", "知识连接", "知识中枢"]
}
```
### Concept
```json
{
"id": "concept_xxxxx",
"type": "concept",
"name": "LinkMind",
"normalizedName": "linkmind",
"salience": 0.67
}
```
### LinkEdge
```json
{
"id": "link_xxxxx",
"type": "mentions",
"fromId": "frag_xxxxx",
"fromType": "fragment",
"toId": "concept_xxxxx",
"toType": "concept",
"score": 2
}
```
## 自测方法
```bash
cd skills/linkmind
node tests/smoke-test.js
```
## 目录结构
```
skills/linkmind/
├── SKILL.md
├── README.md
├── package.json
├── src/
│ ├── index.js # CLI 入口
│ ├── retriever.js # 双层检索
│ ├── storage-adapters/
│ │ ├── StorageAdapter.js # 接口契约
│ │ ├── JsonStorageAdapter.js # JSON 文件实现
│ │ ├── SqliteStorageAdapter.js # SQLite 实现
│ │ └── index.js # Factory
│ ├── embedding-providers/
│ │ ├── EmbeddingProvider.js # 接口契约
│ │ ├── MockProvider.js # 随机向量(测试用)
│ │ ├── OpenAICompatibleProvider.js # OpenAI 兼容 API
│ │ └── index.js # Factory
│ └── utils/
│ └── nlp.js # normalizeConcept 工具
├── dist/
│ └── index.js
├── data/
│ └── workspace.json
├── tests/
│ └── smoke-test.js # Phase 2 覆盖 38 项检查
└── examples/
└── sample-note.md
```
## Phase 2 交付清单
- [x] StorageAdapter 接口契约
- [x] JsonStorageAdapter(向后兼容)
- [x] SqliteStorageAdapter(基于 better-sqlite3)
- [x] EmbeddingProvider 接口契约
- [x] MockProvider(确定性随机、缓存、L2归一化)
- [x] OpenAICompatibleProvider(支持自定义 baseURL/apiKey/model)
- [x] retriever.js:关键词+向量双层召回 + 余弦相似度 ranker + merge 去重
- [x] smoke-test.js Phase 2 全覆盖(38项检查全部通过)
- [x] SKILL.md 更新
## 待后续实现
- ⏳ index.js 接入 adapter 层(DI 注入,支持 --storage=json|sqlite)
- ⏳ 向量批量预计算 + 离线向量存储
- ⏳ 图数据库存储(Neo4j)
- ⏳ Web/开放 API 接口
- ⏳ 多种来源接入(飞书、Notion、URL 抓取)
- ⏳ 证据冲突检测
FILE:data/workspace.json
{
"version": 1,
"createdAt": "2026-04-05T00:44:49.471Z",
"updatedAt": "2026-04-05T00:44:49.514Z",
"documents": [
{
"id": "doc_6bb52r",
"type": "document",
"title": "Smoke Test",
"sourceType": "file",
"sourceUri": "/Users/jianghaidong/.openclaw/workspace/agents/coder/skills/linkmind/tests/smoke-test.js",
"importedAt": "2026-04-05T00:44:49.503Z",
"status": "active",
"text": "#!/usr/bin/env node\n/**\n * LinkMind Smoke Test - Phase 2\n * 覆盖: storage adapters + embedding providers + retriever\n */\nimport path from 'path';\nimport { fileURLToPath } from 'url';\nimport { execSync } from 'child_process';\nimport { pathToFileURL } from 'url';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\nconst SKILL_ROOT = path.resolve(__dirname, '..');\nprocess.chdir(SKILL_ROOT);\n\nfunction run(cmd) {\n console.log(`\\n$ cmd`);\n return JSON.parse(execSync(cmd, { encoding: 'utf8' }));\n}\n\nfunction check(label, cond) {\n if (cond) {\n console.log(` ✓ label`);\n } else {\n console.error(` ✗ FAIL: label`);\n process.exitCode = 1;\n }\n}\n\n// Import phase-2 modules via dynamic import\nconst srcDir = path.join(SKILL_ROOT, 'src');\n\nasync function runTests() {\n console.log('=== LinkMind Smoke Test (Phase 2) ===\\n');\n\n // Phase 1: CLI commands\n console.log('--- Phase 1: CLI Commands ---');\n const reset = run('node dist/index.js reset');\n check('reset returns ok', reset.ok === true);\n\n const empty = run('node dist/index.js status');\n check('empty workspace has 0 documents', empty.documents === 0);\n\n const ingest = run('node dist/index.js ingest --file examples/sample-note.md --title \"Sample Note\"');\n check('ingest returns documentId', !!ingest.documentId);\n check('ingest creates fragments', ingest.fragmentsCreated >= 1);\n\n const after = run('node dist/index.js status');\n check('has 1 document', after.documents === 1);\n check('has fragments', after.fragments >= 1);\n\n const q1 = run('node dist/index.js query --q \"knowledge\"');\n check('query \"knowledge\" finds evidence', q1.evidence && q1.evidence.length >= 1);\n check('query \"knowledge\" returns answer', !!q1.answer);\n\n const q2 = run('node dist/index.js query --q \"xyznonexistentterm12345\"');\n check('query no-match returns empty evidence', q2.evidence && q2.evidence.length === 0);\n\n const q3 = run('node dist/index.js query --q \"knowledge\" --limit 1');\n check('query with limit respects limit', q3.evidence && q3.evidence.length <= 1);\n\n // Phase 2: Storage Adapters\n console.log('\\n--- Phase 2: Storage Adapters ---');\n const JsonStorageAdapterMod = await import(pathToFileURL(path.join(srcDir, 'storage-adapters/JsonStorageAdapter.js')).href);\n const StorageAdapterMod = await import(pathToFileURL(path.join(srcDir, 'storage-adapters/StorageAdapter.js')).href);\n const { JsonStorageAdapter } = JsonStorageAdapterMod;\n const { StorageAdapter } = StorageAdapterMod;\n\n const jsAdapter = new JsonStorageAdapter();\n await jsAdapter.init();\n await jsAdapter.clear();\n check('JsonStorageAdapter.init() ok', true);\n\n const testDoc = {\n id: 'doc_test1',\n type: 'document',\n title: 'Test Doc',\n sourceType: 'note',\n sourceUri: '/test/path.md',\n importedAt: new Date().toISOString(),\n status: 'active',\n text: 'This is a test document for storage adapter testing.'\n };\n await jsAdapter.saveDocument(testDoc);\n const retrieved = await jsAdapter.getDocument('doc_test1');\n check('JsonStorageAdapter.saveDocument + getDocument', retrieved && retrieved.title === 'Test Doc');\n\n const docs = await jsAdapter.listDocuments();\n check('JsonStorageAdapter.listDocuments', docs && docs.length >= 1);\n\n const testFrag = {\n id: 'frag_test1',\n type: 'fragment',\n documentId: 'doc_test1',\n index: 0,\n text: 'This is a test fragment.',\n summary: 'This is a test',\n conceptNames: ['test'],\n createdAt: new Date().toISOString()\n };\n await jsAdapter.saveFragment(testFrag);\n const frag = await jsAdapter.getFragment('frag_test1');\n check('JsonStorageAdapter.saveFragment + getFragment', frag && frag.text.includes('test fragment'));\n\n const frags = await jsAdapter.listFragments('doc_test1');\n check('JsonStorageAdapter.listFragments', frags && frags.length >= 1);\n\n const testConcept = {\n id: 'concept_test1',\n type: 'concept',\n name: 'TestConcept',\n normalizedName: 'testconcept',\n salience: 0.5,\n createdAt: new Date().toISOString()\n };\n await jsAdapter.saveConcept(testConcept);\n const concepts = await jsAdapter.listConcepts();\n check('JsonStorageAdapter.listConcepts', concepts && concepts.some(c => c.normalizedName === 'testconcept'));\n\n const testLink = {\n id: 'link_test1',\n type: 'mentions',\n fromId: 'frag_test1',\n fromType: 'fragment',\n toId: 'concept_test1',\n toType: 'concept',\n documentId: 'doc_test1',\n score: 1,\n createdAt: new Date().toISOString()\n };\n await jsAdapter.saveLink(testLink);\n const links = await jsAdapter.getLinks('frag_test1', null);\n check('JsonStorageAdapter.saveLink + getLinks', links && links.some(l => l.id === 'link_test1'));\n\n // test bulk save\n await jsAdapter.clear();\n await jsAdapter.saveDocument(testDoc);\n await jsAdapter.saveFragments([testFrag]);\n await jsAdapter.saveConcepts([testConcept]);\n await jsAdapter.saveLinks([testLink]);\n const afterBulk = await jsAdapter.listFragments('doc_test1');\n check('JsonStorageAdapter.saveFragments bulk', afterBulk && afterBulk.length >= 1);\n\n await jsAdapter.clear();\n const emptyAfterClear = await jsAdapter.listDocuments();\n check('JsonStorageAdapter.clear()', emptyAfterClear.length === 0);\n\n // StorageAdapter interface\n check('StorageAdapter is a class', typeof StorageAdapter === 'function');\n\n // Phase 2: Embedding Providers\n console.log('\\n--- Phase 2: Embedding Providers ---');\n const MockProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/MockProvider.js')).href);\n const OpenAICompatibleProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/OpenAICompatibleProvider.js')).href);\n const EmbeddingProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/EmbeddingProvider.js')).href);\n const { MockProvider } = MockProviderMod;\n const { OpenAICompatibleProvider } = OpenAICompatibleProviderMod;\n const { EmbeddingProvider } = EmbeddingProviderMod;\n\n const mock = new MockProvider({ dimension: 128 });\n check('MockProvider.dimension', mock.dimension === 128);\n check('MockProvider.name', mock.name === 'mock');\n\n const [vec] = await mock.embed(['hello world']);\n check('MockProvider.embed returns vector', Array.isArray(vec) && vec.length === 128);\n check('MockProvider.vector is L2-normalized', Math.abs(vec.reduce((s, v) => s + v * v, 0) - 1) < 0.01);\n\n const [vec2] = await mock.embed(['hello world']);\n check('MockProvider caches results', vec2 === vec);\n\n const differentText = await mock.embed(['different text']);\n check('MockProvider different text yields different vector', differentText[0] !== vec);\n\n const openai = new OpenAICompatibleProvider({ baseURL: 'https://api.example.com/v1', apiKey: 'test-key', model: 'test-model', dimension: 256 });\n check('OpenAICompatibleProvider.dimension', openai.dimension === 256);\n check('OpenAICompatibleProvider.name includes model', openai.name.includes('test-model'));\n check('OpenAICompatibleProvider.name includes baseURL hint', openai.name.includes('openai-compatible'));\n\n check('EmbeddingProvider is a class', typeof EmbeddingProvider === 'function');\n\n // Phase 2: Retriever\n console.log('\\n--- Phase 2: Retriever ---');\n const retrieverMod = await import(pathToFileURL(path.join(srcDir, 'retriever.js')).href);\n const { keywordSearch, vectorSearch, mergeResults, cosineSimilarity, retrieve } = retrieverMod;\n\n check('cosineSimilarity identical vectors = 1', Math.abs(cosineSimilarity([0.5, 0.5, 0.5, 0.5], [0.5, 0.5, 0.5, 0.5]) - 1) < 0.0001);\n check('cosineSimilarity orthogonal = 0', Math.abs(cosineSimilarity([1, 0, 0], [0, 1, 0])) < 0.0001);\n\n const testFrags = [\n { id: 'f1', documentId: 'd1', documentTitle: 'Doc 1', index: 0, text: 'knowledge graph is useful', conceptNames: ['knowledge', 'graph'] },\n { id: 'f2', documentId: 'd1', documentTitle: 'Doc 1', index: 1, text: 'machine learning and AI', conceptNames: ['machine', 'learning'] },\n { id: 'f3', documentId: 'd2', documentTitle: 'Doc 2', index: 0, text: 'knowledge base systems', conceptNames: ['knowledge', 'base'] }\n ];\n\n const kw = keywordSearch(testFrags, 'knowledge');\n check('keywordSearch finds knowledge fragments', kw.length >= 2);\n check('keywordSearch assigns score > 0', kw.every(r => r.score > 0));\n check('keywordSearch source=keyword', kw.every(r => r.source === 'keyword'));\n\n const vecResults = await vectorSearch(testFrags, 'knowledge graph', mock);\n check('vectorSearch returns results', Array.isArray(vecResults));\n check('vectorSearch source=vector', vecResults.every(r => r.source === 'vector'));\n\n const merged = mergeResults(kw, vecResults, 5);\n check('mergeResults deduplicates', merged.length <= kw.length + vecResults.length);\n check('mergeResults returns array', Array.isArray(merged));\n check('mergeResults items have score', merged.every(r => typeof r.score === 'number'));\n\n const kwOnly = await retrieve({ fragments: testFrags, query: 'knowledge', limit: 5 });\n check('retrieve keyword-only works', kwOnly.length >= 1);\n\n const hybrid = await retrieve({ fragments: testFrags, query: 'knowledge', embeddingProvider: mock, limit: 5 });\n check('retrieve hybrid works', hybrid.length >= 1);\n check('retrieve hybrid may include vector source', hybrid.some(r => r.source === 'vector' || r.source === 'hybrid'));\n\n console.log('\\n=== Smoke Test Complete ===');\n if (process.exitCode === 1) {\n console.log('RESULT: FAILED');\n process.exit(1);\n } else {\n console.log('RESULT: PASSED');\n }\n}\n\nrunTests().catch((err) => {\n console.error('Test error:', err);\n process.exit(1);\n});\n"
}
],
"fragments": [
{
"id": "frag_d3g9ts",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 0,
"text": "#!/usr/bin/env node\n/**\n * LinkMind Smoke Test - Phase 2\n * 覆盖: storage adapters + embedding providers + retriever\n */\nimport path from 'path';\nimport { fileURLToPath } from 'url';\nimport { execSync } from 'child_process';\nimport { pathToFileURL } from 'url';",
"summary": "#!/usr/bin/env node\n/**\n * LinkMind Smoke Test - Phase 2\n * 覆盖: storage adapters + embedding provide",
"conceptNames": [
"import",
"path",
"url",
"adapters",
"bin",
"child_process"
]
},
{
"id": "frag_pmaij6",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 1,
"text": "const __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\nconst SKILL_ROOT = path.resolve(__dirname, '..');\nprocess.chdir(SKILL_ROOT);",
"summary": "const __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\nconst",
"conceptNames": [
"const",
"dirname",
"filename",
"path",
"skill_root",
"chdir"
]
},
{
"id": "frag_dw2gxf",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 2,
"text": "function run(cmd) {\n console.log(`\\n$ cmd`);\n return JSON.parse(execSync(cmd, { encoding: 'utf8' }));\n}",
"summary": "function run(cmd) {\n console.log(`\\n$ cmd`);\n return JSON.parse(execSync(cmd, { encoding: 'utf8",
"conceptNames": [
"cmd",
"console",
"encoding",
"execsync",
"function",
"json"
]
},
{
"id": "frag_1qvdbx",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 3,
"text": "function check(label, cond) {\n if (cond) {\n console.log(` ✓ label`);\n } else {\n console.error(` ✗ FAIL: label`);\n process.exitCode = 1;\n }\n}",
"summary": "function check(label, cond) {\n if (cond) {\n console.log(` ✓ label`);\n } else {\n console.",
"conceptNames": [
"label",
"cond",
"console",
"check",
"else",
"error"
]
},
{
"id": "frag_2pvrfi",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 4,
"text": "// Import phase-2 modules via dynamic import\nconst srcDir = path.join(SKILL_ROOT, 'src');",
"summary": "// Import phase-2 modules via dynamic import\nconst srcDir = path.join(SKILL_ROOT, 'src');",
"conceptNames": [
"import",
"const",
"dynamic",
"join",
"modules",
"path"
]
},
{
"id": "frag_49spg6",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 5,
"text": "async function runTests() {\n console.log('=== LinkMind Smoke Test (Phase 2) ===\\n');",
"summary": "async function runTests() {\n console.log('=== LinkMind Smoke Test (Phase 2) ===\\n');",
"conceptNames": [
"async",
"console",
"function",
"linkmind",
"log",
"phase"
]
},
{
"id": "frag_l7f27m",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 6,
"text": "// Phase 1: CLI commands\n console.log('--- Phase 1: CLI Commands ---');\n const reset = run('node dist/index.js reset');\n check('reset returns ok', reset.ok === true);",
"summary": "// Phase 1: CLI commands\n console.log('--- Phase 1: CLI Commands ---');\n const reset = run('node d",
"conceptNames": [
"reset",
"cli",
"commands",
"phase",
"check",
"console"
]
},
{
"id": "frag_4yjic",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 7,
"text": "const empty = run('node dist/index.js status');\n check('empty workspace has 0 documents', empty.documents === 0);",
"summary": "const empty = run('node dist/index.js status');\n check('empty workspace has 0 documents', empty.doc",
"conceptNames": [
"empty",
"documents",
"check",
"const",
"dist",
"index"
]
},
{
"id": "frag_qhu1w0",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 8,
"text": "const ingest = run('node dist/index.js ingest --file examples/sample-note.md --title \"Sample Note\"');\n check('ingest returns documentId', !!ingest.documentId);\n check('ingest creates fragments', ingest.fragmentsCreated >= 1);",
"summary": "const ingest = run('node dist/index.js ingest --file examples/sample-note.md --title \"Sample Note\"')",
"conceptNames": [
"ingest",
"check",
"documentid",
"const",
"creates",
"dist"
]
},
{
"id": "frag_3vvxiw",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 9,
"text": "const after = run('node dist/index.js status');\n check('has 1 document', after.documents === 1);\n check('has fragments', after.fragments >= 1);",
"summary": "const after = run('node dist/index.js status');\n check('has 1 document', after.documents === 1);\n ",
"conceptNames": [
"after",
"check",
"fragments",
"const",
"dist",
"document"
]
},
{
"id": "frag_o5huex",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 10,
"text": "const q1 = run('node dist/index.js query --q \"knowledge\"');\n check('query \"knowledge\" finds evidence', q1.evidence && q1.evidence.length >= 1);\n check('query \"knowledge\" returns answer', !!q1.answer);",
"summary": "const q1 = run('node dist/index.js query --q \"knowledge\"');\n check('query \"knowledge\" finds evidenc",
"conceptNames": [
"evidence",
"knowledge",
"query",
"answer",
"check",
"const"
]
},
{
"id": "frag_yice37",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 11,
"text": "const q2 = run('node dist/index.js query --q \"xyznonexistentterm12345\"');\n check('query no-match returns empty evidence', q2.evidence && q2.evidence.length === 0);",
"summary": "const q2 = run('node dist/index.js query --q \"xyznonexistentterm12345\"');\n check('query no-match re",
"conceptNames": [
"evidence",
"query",
"check",
"const",
"dist",
"empty"
]
},
{
"id": "frag_qso8rl",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 12,
"text": "const q3 = run('node dist/index.js query --q \"knowledge\" --limit 1');\n check('query with limit respects limit', q3.evidence && q3.evidence.length <= 1);",
"summary": "const q3 = run('node dist/index.js query --q \"knowledge\" --limit 1');\n check('query with limit resp",
"conceptNames": [
"limit",
"evidence",
"query",
"check",
"const",
"dist"
]
},
{
"id": "frag_83cldn",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 13,
"text": "// Phase 2: Storage Adapters\n console.log('\\n--- Phase 2: Storage Adapters ---');\n const JsonStorageAdapterMod = await import(pathToFileURL(path.join(srcDir, 'storage-adapters/JsonStorageAdapter.js')).href);\n const StorageAdapterMod = await import(pathToFileURL(path.join(srcDir, 'storage-adapters/StorageAdapter.js')).href);\n const { JsonStorageAdapter } = JsonStorageAdapterMod;\n const { StorageAdapter } = StorageAdapterMod;",
"summary": "// Phase 2: Storage Adapters\n console.log('\\n--- Phase 2: Storage Adapters ---');\n const JsonStora",
"conceptNames": [
"const",
"adapters",
"await",
"href",
"import",
"join"
]
},
{
"id": "frag_ou8eku",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 14,
"text": "const jsAdapter = new JsonStorageAdapter();\n await jsAdapter.init();\n await jsAdapter.clear();\n check('JsonStorageAdapter.init() ok', true);",
"summary": "const jsAdapter = new JsonStorageAdapter();\n await jsAdapter.init();\n await jsAdapter.clear();\n c",
"conceptNames": [
"jsadapter",
"await",
"init",
"jsonstorageadapter",
"check",
"clear"
]
},
{
"id": "frag_mjx4px",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 15,
"text": "const testDoc = {\n id: 'doc_test1',\n type: 'document',\n title: 'Test Doc',\n sourceType: 'note',\n sourceUri: '/test/path.md',\n importedAt: new Date().toISOString(),\n status: 'active',\n text: 'This is a test document for storage adapter testing.'\n };\n await jsAdapter.saveDocument(testDoc);\n const retrieved = await jsAdapter.getDocument('doc_test1');\n check('JsonStorageAdapter.saveDocument + getDocument', retrieved && retrieved.title === 'Test Doc');",
"summary": "const testDoc = {\n id: 'doc_test1',\n type: 'document',\n title: 'Test Doc',\n sourceType: ",
"conceptNames": [
"test",
"retrieved",
"await",
"const",
"doc",
"doc_test1"
]
},
{
"id": "frag_xgccse",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 16,
"text": "const docs = await jsAdapter.listDocuments();\n check('JsonStorageAdapter.listDocuments', docs && docs.length >= 1);",
"summary": "const docs = await jsAdapter.listDocuments();\n check('JsonStorageAdapter.listDocuments', docs && do",
"conceptNames": [
"docs",
"listdocuments",
"await",
"check",
"const",
"jsadapter"
]
},
{
"id": "frag_4t3zpb",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 17,
"text": "const testFrag = {\n id: 'frag_test1',\n type: 'fragment',\n documentId: 'doc_test1',\n index: 0,\n text: 'This is a test fragment.',\n summary: 'This is a test',\n conceptNames: ['test'],\n createdAt: new Date().toISOString()\n };\n await jsAdapter.saveFragment(testFrag);\n const frag = await jsAdapter.getFragment('frag_test1');\n check('JsonStorageAdapter.saveFragment + getFragment', frag && frag.text.includes('test fragment'));",
"summary": "const testFrag = {\n id: 'frag_test1',\n type: 'fragment',\n documentId: 'doc_test1',\n inde",
"conceptNames": [
"test",
"frag",
"fragment",
"await",
"const",
"frag_test1"
]
},
{
"id": "frag_g3bxss",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 18,
"text": "const frags = await jsAdapter.listFragments('doc_test1');\n check('JsonStorageAdapter.listFragments', frags && frags.length >= 1);",
"summary": "const frags = await jsAdapter.listFragments('doc_test1');\n check('JsonStorageAdapter.listFragments'",
"conceptNames": [
"frags",
"listfragments",
"await",
"check",
"const",
"doc_test1"
]
},
{
"id": "frag_g30jd6",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 19,
"text": "const testConcept = {\n id: 'concept_test1',\n type: 'concept',\n name: 'TestConcept',\n normalizedName: 'testconcept',\n salience: 0.5,\n createdAt: new Date().toISOString()\n };\n await jsAdapter.saveConcept(testConcept);\n const concepts = await jsAdapter.listConcepts();\n check('JsonStorageAdapter.listConcepts', concepts && concepts.some(c => c.normalizedName === 'testconcept'));",
"summary": "const testConcept = {\n id: 'concept_test1',\n type: 'concept',\n name: 'TestConcept',\n nor",
"conceptNames": [
"testconcept",
"concepts",
"await",
"const",
"jsadapter",
"listconcepts"
]
},
{
"id": "frag_h3k825",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 20,
"text": "const testLink = {\n id: 'link_test1',\n type: 'mentions',\n fromId: 'frag_test1',\n fromType: 'fragment',\n toId: 'concept_test1',\n toType: 'concept',\n documentId: 'doc_test1',\n score: 1,\n createdAt: new Date().toISOString()\n };\n await jsAdapter.saveLink(testLink);\n const links = await jsAdapter.getLinks('frag_test1', null);\n check('JsonStorageAdapter.saveLink + getLinks', links && links.some(l => l.id === 'link_test1'));",
"summary": "const testLink = {\n id: 'link_test1',\n type: 'mentions',\n fromId: 'frag_test1',\n fromTyp",
"conceptNames": [
"links",
"await",
"const",
"frag_test1",
"getlinks",
"jsadapter"
]
},
{
"id": "frag_ivfanb",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 21,
"text": "// test bulk save\n await jsAdapter.clear();\n await jsAdapter.saveDocument(testDoc);\n await jsAdapter.saveFragments([testFrag]);\n await jsAdapter.saveConcepts([testConcept]);\n await jsAdapter.saveLinks([testLink]);\n const afterBulk = await jsAdapter.listFragments('doc_test1');\n check('JsonStorageAdapter.saveFragments bulk', afterBulk && afterBulk.length >= 1);",
"summary": "// test bulk save\n await jsAdapter.clear();\n await jsAdapter.saveDocument(testDoc);\n await jsAdap",
"conceptNames": [
"await",
"jsadapter",
"afterbulk",
"bulk",
"savefragments",
"check"
]
},
{
"id": "frag_g1qshr",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 22,
"text": "await jsAdapter.clear();\n const emptyAfterClear = await jsAdapter.listDocuments();\n check('JsonStorageAdapter.clear()', emptyAfterClear.length === 0);",
"summary": "await jsAdapter.clear();\n const emptyAfterClear = await jsAdapter.listDocuments();\n check('JsonSto",
"conceptNames": [
"await",
"clear",
"emptyafterclear",
"jsadapter",
"check",
"const"
]
},
{
"id": "frag_m2z8gs",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 23,
"text": "// StorageAdapter interface\n check('StorageAdapter is a class', typeof StorageAdapter === 'function');",
"summary": "// StorageAdapter interface\n check('StorageAdapter is a class', typeof StorageAdapter === 'function",
"conceptNames": [
"storageadapter",
"check",
"class",
"function",
"interface",
"typeof"
]
},
{
"id": "frag_la8iqs",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 24,
"text": "// Phase 2: Embedding Providers\n console.log('\\n--- Phase 2: Embedding Providers ---');\n const MockProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/MockProvider.js')).href);\n const OpenAICompatibleProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/OpenAICompatibleProvider.js')).href);\n const EmbeddingProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/EmbeddingProvider.js')).href);\n const { MockProvider } = MockProviderMod;\n const { OpenAICompatibleProvider } = OpenAICompatibleProviderMod;\n const { EmbeddingProvider } = EmbeddingProviderMod;",
"summary": "// Phase 2: Embedding Providers\n console.log('\\n--- Phase 2: Embedding Providers ---');\n const Moc",
"conceptNames": [
"const",
"await",
"embedding-providers",
"href",
"import",
"join"
]
},
{
"id": "frag_k2wcc",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 25,
"text": "const mock = new MockProvider({ dimension: 128 });\n check('MockProvider.dimension', mock.dimension === 128);\n check('MockProvider.name', mock.name === 'mock');",
"summary": "const mock = new MockProvider({ dimension: 128 });\n check('MockProvider.dimension', mock.dimension ",
"conceptNames": [
"mock",
"dimension",
"mockprovider",
"check",
"name",
"const"
]
},
{
"id": "frag_amqxax",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 26,
"text": "const [vec] = await mock.embed(['hello world']);\n check('MockProvider.embed returns vector', Array.isArray(vec) && vec.length === 128);\n check('MockProvider.vector is L2-normalized', Math.abs(vec.reduce((s, v) => s + v * v, 0) - 1) < 0.01);",
"summary": "const [vec] = await mock.embed(['hello world']);\n check('MockProvider.embed returns vector', Array.",
"conceptNames": [
"vec",
"check",
"embed",
"mockprovider",
"vector",
"abs"
]
},
{
"id": "frag_k3j5ot",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 27,
"text": "const [vec2] = await mock.embed(['hello world']);\n check('MockProvider caches results', vec2 === vec);",
"summary": "const [vec2] = await mock.embed(['hello world']);\n check('MockProvider caches results', vec2 === ve",
"conceptNames": [
"vec2",
"await",
"caches",
"check",
"const",
"embed"
]
},
{
"id": "frag_oxk0al",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 28,
"text": "const differentText = await mock.embed(['different text']);\n check('MockProvider different text yields different vector', differentText[0] !== vec);",
"summary": "const differentText = await mock.embed(['different text']);\n check('MockProvider different text yie",
"conceptNames": [
"different",
"differenttext",
"text",
"await",
"check",
"const"
]
},
{
"id": "frag_w7ug9l",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 29,
"text": "const openai = new OpenAICompatibleProvider({ baseURL: 'https://api.example.com/v1', apiKey: 'test-key', model: 'test-model', dimension: 256 });\n check('OpenAICompatibleProvider.dimension', openai.dimension === 256);\n check('OpenAICompatibleProvider.name includes model', openai.name.includes('test-model'));\n check('OpenAICompatibleProvider.name includes baseURL hint', openai.name.includes('openai-compatible'));",
"summary": "const openai = new OpenAICompatibleProvider({ baseURL: 'https://api.example.com/v1', apiKey: 'test-k",
"conceptNames": [
"includes",
"name",
"openai",
"openaicompatibleprovider",
"check",
"dimension"
]
},
{
"id": "frag_h1uzlu",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 30,
"text": "check('EmbeddingProvider is a class', typeof EmbeddingProvider === 'function');",
"summary": "check('EmbeddingProvider is a class', typeof EmbeddingProvider === 'function');",
"conceptNames": [
"embeddingprovider",
"check",
"class",
"function",
"typeof"
]
},
{
"id": "frag_rrcn8h",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 31,
"text": "// Phase 2: Retriever\n console.log('\\n--- Phase 2: Retriever ---');\n const retrieverMod = await import(pathToFileURL(path.join(srcDir, 'retriever.js')).href);\n const { keywordSearch, vectorSearch, mergeResults, cosineSimilarity, retrieve } = retrieverMod;",
"summary": "// Phase 2: Retriever\n console.log('\\n--- Phase 2: Retriever ---');\n const retrieverMod = await im",
"conceptNames": [
"retriever",
"const",
"phase",
"retrievermod",
"await",
"console"
]
},
{
"id": "frag_z6ich8",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 32,
"text": "check('cosineSimilarity identical vectors = 1', Math.abs(cosineSimilarity([0.5, 0.5, 0.5, 0.5], [0.5, 0.5, 0.5, 0.5]) - 1) < 0.0001);\n check('cosineSimilarity orthogonal = 0', Math.abs(cosineSimilarity([1, 0, 0], [0, 1, 0])) < 0.0001);",
"summary": "check('cosineSimilarity identical vectors = 1', Math.abs(cosineSimilarity([0.5, 0.5, 0.5, 0.5], [0.5",
"conceptNames": [
"cosinesimilarity",
"abs",
"check",
"math",
"identical",
"orthogonal"
]
},
{
"id": "frag_5i1o5v",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 33,
"text": "const testFrags = [\n { id: 'f1', documentId: 'd1', documentTitle: 'Doc 1', index: 0, text: 'knowledge graph is useful', conceptNames: ['knowledge', 'graph'] },\n { id: 'f2', documentId: 'd1', documentTitle: 'Doc 1', index: 1, text: 'machine learning and AI', conceptNames: ['machine', 'learning'] },\n { id: 'f3', documentId: 'd2', documentTitle: 'Doc 2', index: 0, text: 'knowledge base systems', conceptNames: ['knowledge', 'base'] }\n ];",
"summary": "const testFrags = [\n { id: 'f1', documentId: 'd1', documentTitle: 'Doc 1', index: 0, text: 'knowl",
"conceptNames": [
"knowledge",
"conceptnames",
"doc",
"documentid",
"documenttitle",
"index"
]
},
{
"id": "frag_1wv3le",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 34,
"text": "const kw = keywordSearch(testFrags, 'knowledge');\n check('keywordSearch finds knowledge fragments', kw.length >= 2);\n check('keywordSearch assigns score > 0', kw.every(r => r.score > 0));\n check('keywordSearch source=keyword', kw.every(r => r.source === 'keyword'));",
"summary": "const kw = keywordSearch(testFrags, 'knowledge');\n check('keywordSearch finds knowledge fragments',",
"conceptNames": [
"keywordsearch",
"check",
"every",
"keyword",
"knowledge",
"score"
]
},
{
"id": "frag_jx95go",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 35,
"text": "const vecResults = await vectorSearch(testFrags, 'knowledge graph', mock);\n check('vectorSearch returns results', Array.isArray(vecResults));\n check('vectorSearch source=vector', vecResults.every(r => r.source === 'vector'));",
"summary": "const vecResults = await vectorSearch(testFrags, 'knowledge graph', mock);\n check('vectorSearch ret",
"conceptNames": [
"vecresults",
"vectorsearch",
"check",
"source",
"vector",
"array"
]
},
{
"id": "frag_mb8wqq",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 36,
"text": "const merged = mergeResults(kw, vecResults, 5);\n check('mergeResults deduplicates', merged.length <= kw.length + vecResults.length);\n check('mergeResults returns array', Array.isArray(merged));\n check('mergeResults items have score', merged.every(r => typeof r.score === 'number'));",
"summary": "const merged = mergeResults(kw, vecResults, 5);\n check('mergeResults deduplicates', merged.length <",
"conceptNames": [
"merged",
"mergeresults",
"check",
"length",
"array",
"score"
]
},
{
"id": "frag_c2gwcq",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 37,
"text": "const kwOnly = await retrieve({ fragments: testFrags, query: 'knowledge', limit: 5 });\n check('retrieve keyword-only works', kwOnly.length >= 1);",
"summary": "const kwOnly = await retrieve({ fragments: testFrags, query: 'knowledge', limit: 5 });\n check('retr",
"conceptNames": [
"kwonly",
"retrieve",
"await",
"check",
"const",
"fragments"
]
},
{
"id": "frag_lybfjt",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 38,
"text": "const hybrid = await retrieve({ fragments: testFrags, query: 'knowledge', embeddingProvider: mock, limit: 5 });\n check('retrieve hybrid works', hybrid.length >= 1);\n check('retrieve hybrid may include vector source', hybrid.some(r => r.source === 'vector' || r.source === 'hybrid'));",
"summary": "const hybrid = await retrieve({ fragments: testFrags, query: 'knowledge', embeddingProvider: mock, l",
"conceptNames": [
"hybrid",
"retrieve",
"source",
"check",
"vector",
"await"
]
},
{
"id": "frag_4nve2i",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 39,
"text": "console.log('\\n=== Smoke Test Complete ===');\n if (process.exitCode === 1) {\n console.log('RESULT: FAILED');\n process.exit(1);\n } else {\n console.log('RESULT: PASSED');\n }\n}",
"summary": "console.log('\\n=== Smoke Test Complete ===');\n if (process.exitCode === 1) {\n console.log('RESUL",
"conceptNames": [
"console",
"log",
"process",
"result",
"complete",
"else"
]
},
{
"id": "frag_h74lv",
"type": "fragment",
"documentId": "doc_6bb52r",
"index": 40,
"text": "runTests().catch((err) => {\n console.error('Test error:', err);\n process.exit(1);\n});",
"summary": "runTests().catch((err) => {\n console.error('Test error:', err);\n process.exit(1);\n});",
"conceptNames": [
"err",
"error",
"catch",
"console",
"exit",
"process"
]
}
],
"concepts": [
{
"id": "concept_22cy",
"type": "concept",
"name": "abs",
"normalizedName": "abs",
"salience": 0.3333333333333333
},
{
"id": "concept_llkge4",
"type": "concept",
"name": "adapters",
"normalizedName": "adapters",
"salience": 0.3333333333333333
},
{
"id": "concept_1j7mqk",
"type": "concept",
"name": "after",
"normalizedName": "after",
"salience": 1
},
{
"id": "concept_gv37qm",
"type": "concept",
"name": "afterBulk",
"normalizedName": "afterbulk",
"salience": 1
},
{
"id": "concept_nd5e82",
"type": "concept",
"name": "answer",
"normalizedName": "answer",
"salience": 0.6666666666666666
},
{
"id": "concept_1jf909",
"type": "concept",
"name": "Array",
"normalizedName": "array",
"salience": 0.3333333333333333
},
{
"id": "concept_1jg1h8",
"type": "concept",
"name": "async",
"normalizedName": "async",
"salience": 0.3333333333333333
},
{
"id": "concept_1ji3iu",
"type": "concept",
"name": "await",
"normalizedName": "await",
"salience": 0.6666666666666666
},
{
"id": "concept_239j",
"type": "concept",
"name": "bin",
"normalizedName": "bin",
"salience": 0.3333333333333333
},
{
"id": "concept_1t24y",
"type": "concept",
"name": "bulk",
"normalizedName": "bulk",
"salience": 0.6666666666666666
},
{
"id": "concept_mmi027",
"type": "concept",
"name": "caches",
"normalizedName": "caches",
"salience": 0.3333333333333333
},
{
"id": "concept_1k80xn",
"type": "concept",
"name": "catch",
"normalizedName": "catch",
"salience": 0.3333333333333333
},
{
"id": "concept_1kc64o",
"type": "concept",
"name": "chdir",
"normalizedName": "chdir",
"salience": 0.3333333333333333
},
{
"id": "concept_1kc6q0",
"type": "concept",
"name": "check",
"normalizedName": "check",
"salience": 0.3333333333333333
},
{
"id": "concept_86zfzo",
"type": "concept",
"name": "child_process",
"normalizedName": "child_process",
"salience": 0.3333333333333333
},
{
"id": "concept_1keo3c",
"type": "concept",
"name": "class",
"normalizedName": "class",
"salience": 0.3333333333333333
},
{
"id": "concept_1keqml",
"type": "concept",
"name": "clear",
"normalizedName": "clear",
"salience": 0.3333333333333333
},
{
"id": "concept_242o",
"type": "concept",
"name": "CLI",
"normalizedName": "cli",
"salience": 0.6666666666666666
},
{
"id": "concept_243e",
"type": "concept",
"name": "cmd",
"normalizedName": "cmd",
"salience": 1
},
{
"id": "concept_9yqf7c",
"type": "concept",
"name": "commands",
"normalizedName": "commands",
"salience": 0.6666666666666666
},
{
"id": "concept_9ww6vb",
"type": "concept",
"name": "Complete",
"normalizedName": "complete",
"salience": 0.3333333333333333
},
{
"id": "concept_gr1mao",
"type": "concept",
"name": "conceptNames",
"normalizedName": "conceptnames",
"salience": 1
},
{
"id": "concept_9n44xh",
"type": "concept",
"name": "concepts",
"normalizedName": "concepts",
"salience": 1
},
{
"id": "concept_1tkpu",
"type": "concept",
"name": "cond",
"normalizedName": "cond",
"salience": 0.6666666666666666
},
{
"id": "concept_fqi63b",
"type": "concept",
"name": "console",
"normalizedName": "console",
"salience": 0.3333333333333333
},
{
"id": "concept_1kguoz",
"type": "concept",
"name": "const",
"normalizedName": "const",
"salience": 1
},
{
"id": "concept_hkekis",
"type": "concept",
"name": "cosineSimilarity",
"normalizedName": "cosinesimilarity",
"salience": 1
},
{
"id": "concept_h0dhpz",
"type": "concept",
"name": "creates",
"normalizedName": "creates",
"salience": 0.3333333333333333
},
{
"id": "concept_ljky0p",
"type": "concept",
"name": "different",
"normalizedName": "different",
"salience": 1
},
{
"id": "concept_q1bzdy",
"type": "concept",
"name": "differentText",
"normalizedName": "differenttext",
"salience": 0.6666666666666666
},
{
"id": "concept_i3xxga",
"type": "concept",
"name": "dimension",
"normalizedName": "dimension",
"salience": 1
},
{
"id": "concept_rmqf14",
"type": "concept",
"name": "dirname",
"normalizedName": "dirname",
"salience": 1
},
{
"id": "concept_1u3dy",
"type": "concept",
"name": "dist",
"normalizedName": "dist",
"salience": 0.3333333333333333
},
{
"id": "concept_24vs",
"type": "concept",
"name": "Doc",
"normalizedName": "doc",
"salience": 0.6666666666666666
},
{
"id": "concept_5amdjs",
"type": "concept",
"name": "doc_test1",
"normalizedName": "doc_test1",
"salience": 0.6666666666666666
},
{
"id": "concept_1u7gb",
"type": "concept",
"name": "docs",
"normalizedName": "docs",
"salience": 1
},
{
"id": "concept_e91o2j",
"type": "concept",
"name": "document",
"normalizedName": "document",
"salience": 0.3333333333333333
},
{
"id": "concept_dh6z62",
"type": "concept",
"name": "documentId",
"normalizedName": "documentid",
"salience": 0.6666666666666666
},
{
"id": "concept_flreew",
"type": "concept",
"name": "documents",
"normalizedName": "documents",
"salience": 0.6666666666666666
},
{
"id": "concept_qjcwf1",
"type": "concept",
"name": "documentTitle",
"normalizedName": "documenttitle",
"salience": 1
},
{
"id": "concept_z514xr",
"type": "concept",
"name": "dynamic",
"normalizedName": "dynamic",
"salience": 0.3333333333333333
},
{
"id": "concept_1usl5",
"type": "concept",
"name": "else",
"normalizedName": "else",
"salience": 0.3333333333333333
},
{
"id": "concept_1liwnt",
"type": "concept",
"name": "embed",
"normalizedName": "embed",
"salience": 0.6666666666666666
},
{
"id": "concept_7liuz8",
"type": "concept",
"name": "embedding-providers",
"normalizedName": "embedding-providers",
"salience": 1
},
{
"id": "concept_w9ow8",
"type": "concept",
"name": "EmbeddingProvider",
"normalizedName": "embeddingprovider",
"salience": 0.6666666666666666
},
{
"id": "concept_1lj7f1",
"type": "concept",
"name": "empty",
"normalizedName": "empty",
"salience": 1
},
{
"id": "concept_dlzsam",
"type": "concept",
"name": "emptyAfterClear",
"normalizedName": "emptyafterclear",
"salience": 0.6666666666666666
},
{
"id": "concept_satff7",
"type": "concept",
"name": "encoding",
"normalizedName": "encoding",
"salience": 0.3333333333333333
},
{
"id": "concept_25ph",
"type": "concept",
"name": "err",
"normalizedName": "err",
"salience": 0.6666666666666666
},
{
"id": "concept_1lmfpk",
"type": "concept",
"name": "error",
"normalizedName": "error",
"salience": 0.3333333333333333
},
{
"id": "concept_1loq3f",
"type": "concept",
"name": "every",
"normalizedName": "every",
"salience": 0.6666666666666666
},
{
"id": "concept_6c0biv",
"type": "concept",
"name": "evidence",
"normalizedName": "evidence",
"salience": 1
},
{
"id": "concept_xsbs18",
"type": "concept",
"name": "execSync",
"normalizedName": "execsync",
"salience": 0.3333333333333333
},
{
"id": "concept_1v19a",
"type": "concept",
"name": "exit",
"normalizedName": "exit",
"salience": 0.3333333333333333
},
{
"id": "concept_c5gn3t",
"type": "concept",
"name": "filename",
"normalizedName": "filename",
"salience": 0.6666666666666666
},
{
"id": "concept_1vjle",
"type": "concept",
"name": "frag",
"normalizedName": "frag",
"salience": 1
},
{
"id": "concept_o1b6i6",
"type": "concept",
"name": "frag_test1",
"normalizedName": "frag_test1",
"salience": 0.6666666666666666
},
{
"id": "concept_raj06o",
"type": "concept",
"name": "fragment",
"normalizedName": "fragment",
"salience": 1
},
{
"id": "concept_6azi1v",
"type": "concept",
"name": "fragments",
"normalizedName": "fragments",
"salience": 0.6666666666666666
},
{
"id": "concept_1m5vi9",
"type": "concept",
"name": "frags",
"normalizedName": "frags",
"salience": 1
},
{
"id": "concept_mu6b4o",
"type": "concept",
"name": "function",
"normalizedName": "function",
"salience": 0.3333333333333333
},
{
"id": "concept_wvtd0j",
"type": "concept",
"name": "getLinks",
"normalizedName": "getlinks",
"salience": 0.6666666666666666
},
{
"id": "concept_1wtnv",
"type": "concept",
"name": "href",
"normalizedName": "href",
"salience": 0.6666666666666666
},
{
"id": "concept_jw39c4",
"type": "concept",
"name": "hybrid",
"normalizedName": "hybrid",
"salience": 1
},
{
"id": "concept_1fehth",
"type": "concept",
"name": "identical",
"normalizedName": "identical",
"salience": 0.3333333333333333
},
{
"id": "concept_jlea8r",
"type": "concept",
"name": "import",
"normalizedName": "import",
"salience": 1
},
{
"id": "concept_1hqksr",
"type": "concept",
"name": "includes",
"normalizedName": "includes",
"salience": 1
},
{
"id": "concept_1nqriq",
"type": "concept",
"name": "index",
"normalizedName": "index",
"salience": 0.3333333333333333
},
{
"id": "concept_jl0fx8",
"type": "concept",
"name": "ingest",
"normalizedName": "ingest",
"salience": 1
},
{
"id": "concept_1xdsg",
"type": "concept",
"name": "init",
"normalizedName": "init",
"salience": 0.6666666666666666
},
{
"id": "concept_8b8yt5",
"type": "concept",
"name": "interface",
"normalizedName": "interface",
"salience": 0.3333333333333333
},
{
"id": "concept_1y1ii",
"type": "concept",
"name": "join",
"normalizedName": "join",
"salience": 0.3333333333333333
},
{
"id": "concept_f09bfe",
"type": "concept",
"name": "jsAdapter",
"normalizedName": "jsadapter",
"salience": 1
},
{
"id": "concept_1y4mg",
"type": "concept",
"name": "JSON",
"normalizedName": "json",
"salience": 0.3333333333333333
},
{
"id": "concept_b0w3to",
"type": "concept",
"name": "JsonStorageAdapter",
"normalizedName": "jsonstorageadapter",
"salience": 0.6666666666666666
},
{
"id": "concept_dgvlef",
"type": "concept",
"name": "keyword",
"normalizedName": "keyword",
"salience": 0.6666666666666666
},
{
"id": "concept_ofkd9r",
"type": "concept",
"name": "keywordSearch",
"normalizedName": "keywordsearch",
"salience": 1
},
{
"id": "concept_pmrgxq",
"type": "concept",
"name": "knowledge",
"normalizedName": "knowledge",
"salience": 1
},
{
"id": "concept_ihtre0",
"type": "concept",
"name": "kwOnly",
"normalizedName": "kwonly",
"salience": 0.6666666666666666
},
{
"id": "concept_1p5sz8",
"type": "concept",
"name": "label",
"normalizedName": "label",
"salience": 1
},
{
"id": "concept_iap7oa",
"type": "concept",
"name": "length",
"normalizedName": "length",
"salience": 1
},
{
"id": "concept_1pb54r",
"type": "concept",
"name": "limit",
"normalizedName": "limit",
"salience": 1
},
{
"id": "concept_jrfmmk",
"type": "concept",
"name": "LinkMind",
"normalizedName": "linkmind",
"salience": 0.3333333333333333
},
{
"id": "concept_1pb5x5",
"type": "concept",
"name": "links",
"normalizedName": "links",
"salience": 1
},
{
"id": "concept_77o2i1",
"type": "concept",
"name": "listConcepts",
"normalizedName": "listconcepts",
"salience": 0.6666666666666666
},
{
"id": "concept_u4un1i",
"type": "concept",
"name": "listDocuments",
"normalizedName": "listdocuments",
"salience": 0.6666666666666666
},
{
"id": "concept_vlhikl",
"type": "concept",
"name": "listFragments",
"normalizedName": "listfragments",
"salience": 0.6666666666666666
},
{
"id": "concept_2atg",
"type": "concept",
"name": "log",
"normalizedName": "log",
"salience": 0.3333333333333333
},
{
"id": "concept_1zoco",
"type": "concept",
"name": "Math",
"normalizedName": "math",
"salience": 0.6666666666666666
},
{
"id": "concept_htl1p0",
"type": "concept",
"name": "merged",
"normalizedName": "merged",
"salience": 1
},
{
"id": "concept_ljhtmq",
"type": "concept",
"name": "mergeResults",
"normalizedName": "mergeresults",
"salience": 1
},
{
"id": "concept_1zybu",
"type": "concept",
"name": "mock",
"normalizedName": "mock",
"salience": 1
},
{
"id": "concept_3bbkvv",
"type": "concept",
"name": "MockProvider",
"normalizedName": "mockprovider",
"salience": 1
},
{
"id": "concept_kas613",
"type": "concept",
"name": "modules",
"normalizedName": "modules",
"salience": 0.3333333333333333
},
{
"id": "concept_20b63",
"type": "concept",
"name": "name",
"normalizedName": "name",
"salience": 0.6666666666666666
},
{
"id": "concept_gpo83y",
"type": "concept",
"name": "openai",
"normalizedName": "openai",
"salience": 1
},
{
"id": "concept_cpps5l",
"type": "concept",
"name": "OpenAICompatibleProvider",
"normalizedName": "openaicompatibleprovider",
"salience": 1
},
{
"id": "concept_j6wxa1",
"type": "concept",
"name": "orthogonal",
"normalizedName": "orthogonal",
"salience": 0.3333333333333333
},
{
"id": "concept_21lb9",
"type": "concept",
"name": "path",
"normalizedName": "path",
"salience": 0.6666666666666666
},
{
"id": "concept_1rhfuj",
"type": "concept",
"name": "Phase",
"normalizedName": "phase",
"salience": 0.3333333333333333
},
{
"id": "concept_54a26p",
"type": "concept",
"name": "process",
"normalizedName": "process",
"salience": 0.6666666666666666
},
{
"id": "concept_1s9m88",
"type": "concept",
"name": "query",
"normalizedName": "query",
"salience": 1
},
{
"id": "concept_1sjh3j",
"type": "concept",
"name": "reset",
"normalizedName": "reset",
"salience": 1
},
{
"id": "concept_fgc06b",
"type": "concept",
"name": "RESULT",
"normalizedName": "result",
"salience": 0.6666666666666666
},
{
"id": "concept_54l41w",
"type": "concept",
"name": "retrieve",
"normalizedName": "retrieve",
"salience": 0.6666666666666666
},
{
"id": "concept_gvydlk",
"type": "concept",
"name": "retrieved",
"normalizedName": "retrieved",
"salience": 1
},
{
"id": "concept_gvydl6",
"type": "concept",
"name": "Retriever",
"normalizedName": "retriever",
"salience": 1
},
{
"id": "concept_cbkjnw",
"type": "concept",
"name": "retrieverMod",
"normalizedName": "retrievermod",
"salience": 0.6666666666666666
},
{
"id": "concept_b6ebeu",
"type": "concept",
"name": "saveFragments",
"normalizedName": "savefragments",
"salience": 0.6666666666666666
},
{
"id": "concept_1t1x1u",
"type": "concept",
"name": "score",
"normalizedName": "score",
"salience": 0.6666666666666666
},
{
"id": "concept_ql1wy8",
"type": "concept",
"name": "SKILL_ROOT",
"normalizedName": "skill_root",
"salience": 0.6666666666666666
},
{
"id": "concept_etr8bp",
"type": "concept",
"name": "source",
"normalizedName": "source",
"salience": 0.6666666666666666
},
{
"id": "concept_7wml4k",
"type": "concept",
"name": "StorageAdapter",
"normalizedName": "storageadapter",
"salience": 1
},
{
"id": "concept_2487m",
"type": "concept",
"name": "Test",
"normalizedName": "test",
"salience": 1
},
{
"id": "concept_iamaui",
"type": "concept",
"name": "testConcept",
"normalizedName": "testconcept",
"salience": 1
},
{
"id": "concept_248bx",
"type": "concept",
"name": "text",
"normalizedName": "text",
"salience": 0.6666666666666666
},
{
"id": "concept_e7b4a7",
"type": "concept",
"name": "typeof",
"normalizedName": "typeof",
"salience": 0.3333333333333333
},
{
"id": "concept_2hkf",
"type": "concept",
"name": "url",
"normalizedName": "url",
"salience": 0.6666666666666666
},
{
"id": "concept_2hzo",
"type": "concept",
"name": "vec",
"normalizedName": "vec",
"salience": 1
},
{
"id": "concept_25hr2",
"type": "concept",
"name": "vec2",
"normalizedName": "vec2",
"salience": 0.6666666666666666
},
{
"id": "concept_xr4ppa",
"type": "concept",
"name": "vecResults",
"normalizedName": "vecresults",
"salience": 1
},
{
"id": "concept_dkfr25",
"type": "concept",
"name": "vector",
"normalizedName": "vector",
"salience": 0.6666666666666666
},
{
"id": "concept_nhnbu3",
"type": "concept",
"name": "vectorSearch",
"normalizedName": "vectorsearch",
"salience": 1
}
],
"links": [
{
"id": "link_gm6aj7",
"type": "mentions",
"fromId": "frag_d3g9ts",
"fromType": "fragment",
"toId": "concept_jlea8r",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_sd7hnz",
"type": "mentions",
"fromId": "frag_d3g9ts",
"fromType": "fragment",
"toId": "concept_21lb9",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_7sfp9i",
"type": "mentions",
"fromId": "frag_d3g9ts",
"fromType": "fragment",
"toId": "concept_2hkf",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_fnz2cg",
"type": "mentions",
"fromId": "frag_d3g9ts",
"fromType": "fragment",
"toId": "concept_llkge4",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_7sekrr",
"type": "mentions",
"fromId": "frag_d3g9ts",
"fromType": "fragment",
"toId": "concept_239j",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_txdbp0",
"type": "mentions",
"fromId": "frag_d3g9ts",
"fromType": "fragment",
"toId": "concept_86zfzo",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_fmd0xz",
"type": "mentions",
"fromId": "frag_pmaij6",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_f6u12x",
"type": "mentions",
"fromId": "frag_pmaij6",
"fromType": "fragment",
"toId": "concept_rmqf14",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_78703k",
"type": "mentions",
"fromId": "frag_pmaij6",
"fromType": "fragment",
"toId": "concept_c5gn3t",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_s0gtgi",
"type": "mentions",
"fromId": "frag_pmaij6",
"fromType": "fragment",
"toId": "concept_21lb9",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_eo459i",
"type": "mentions",
"fromId": "frag_pmaij6",
"fromType": "fragment",
"toId": "concept_ql1wy8",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_fmgx0y",
"type": "mentions",
"fromId": "frag_pmaij6",
"fromType": "fragment",
"toId": "concept_1kc64o",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_zgfly7",
"type": "mentions",
"fromId": "frag_dw2gxf",
"fromType": "fragment",
"toId": "concept_243e",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_4ffy36",
"type": "mentions",
"fromId": "frag_dw2gxf",
"fromType": "fragment",
"toId": "concept_fqi63b",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_acgdcs",
"type": "mentions",
"fromId": "frag_dw2gxf",
"fromType": "fragment",
"toId": "concept_satff7",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_cz9im6",
"type": "mentions",
"fromId": "frag_dw2gxf",
"fromType": "fragment",
"toId": "concept_xsbs18",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_7s2tju",
"type": "mentions",
"fromId": "frag_dw2gxf",
"fromType": "fragment",
"toId": "concept_mu6b4o",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_xpfdbv",
"type": "mentions",
"fromId": "frag_dw2gxf",
"fromType": "fragment",
"toId": "concept_1y4mg",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_yt358s",
"type": "mentions",
"fromId": "frag_1qvdbx",
"fromType": "fragment",
"toId": "concept_1p5sz8",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_ev8u24",
"type": "mentions",
"fromId": "frag_1qvdbx",
"fromType": "fragment",
"toId": "concept_1tkpu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_9o9hc0",
"type": "mentions",
"fromId": "frag_1qvdbx",
"fromType": "fragment",
"toId": "concept_fqi63b",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_yv209n",
"type": "mentions",
"fromId": "frag_1qvdbx",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ev81a9",
"type": "mentions",
"fromId": "frag_1qvdbx",
"fromType": "fragment",
"toId": "concept_1usl5",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_yuau6w",
"type": "mentions",
"fromId": "frag_1qvdbx",
"fromType": "fragment",
"toId": "concept_1lmfpk",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_2zdydy",
"type": "mentions",
"fromId": "frag_2pvrfi",
"fromType": "fragment",
"toId": "concept_jlea8r",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_tzghbv",
"type": "mentions",
"fromId": "frag_2pvrfi",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_3q584q",
"type": "mentions",
"fromId": "frag_2pvrfi",
"fromType": "fragment",
"toId": "concept_z514xr",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_jai59l",
"type": "mentions",
"fromId": "frag_2pvrfi",
"fromType": "fragment",
"toId": "concept_1y1ii",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_2o60sz",
"type": "mentions",
"fromId": "frag_2pvrfi",
"fromType": "fragment",
"toId": "concept_kas613",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_jb746m",
"type": "mentions",
"fromId": "frag_2pvrfi",
"fromType": "fragment",
"toId": "concept_21lb9",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_l8pjy1",
"type": "mentions",
"fromId": "frag_49spg6",
"fromType": "fragment",
"toId": "concept_1jg1h8",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ol4mj7",
"type": "mentions",
"fromId": "frag_49spg6",
"fromType": "fragment",
"toId": "concept_fqi63b",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_l8hr2j",
"type": "mentions",
"fromId": "frag_49spg6",
"fromType": "fragment",
"toId": "concept_mu6b4o",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_mof359",
"type": "mentions",
"fromId": "frag_49spg6",
"fromType": "fragment",
"toId": "concept_jrfmmk",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_cl5qmg",
"type": "mentions",
"fromId": "frag_49spg6",
"fromType": "fragment",
"toId": "concept_2atg",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ld5nbu",
"type": "mentions",
"fromId": "frag_49spg6",
"fromType": "fragment",
"toId": "concept_1rhfuj",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_xcuxjb",
"type": "mentions",
"fromId": "frag_l7f27m",
"fromType": "fragment",
"toId": "concept_1sjh3j",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_iygf27",
"type": "mentions",
"fromId": "frag_l7f27m",
"fromType": "fragment",
"toId": "concept_242o",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_th2rb1",
"type": "mentions",
"fromId": "frag_l7f27m",
"fromType": "fragment",
"toId": "concept_9yqf7c",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_xdg00a",
"type": "mentions",
"fromId": "frag_l7f27m",
"fromType": "fragment",
"toId": "concept_1rhfuj",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_xheqtu",
"type": "mentions",
"fromId": "frag_l7f27m",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_8am7w7",
"type": "mentions",
"fromId": "frag_l7f27m",
"fromType": "fragment",
"toId": "concept_fqi63b",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_gz4d4d",
"type": "mentions",
"fromId": "frag_4yjic",
"fromType": "fragment",
"toId": "concept_1lj7f1",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_syfnx7",
"type": "mentions",
"fromId": "frag_4yjic",
"fromType": "fragment",
"toId": "concept_flreew",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_gyg352",
"type": "mentions",
"fromId": "frag_4yjic",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_gyjxt9",
"type": "mentions",
"fromId": "frag_4yjic",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_awlf12",
"type": "mentions",
"fromId": "frag_4yjic",
"fromType": "fragment",
"toId": "concept_1u3dy",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_h0dn34",
"type": "mentions",
"fromId": "frag_4yjic",
"fromType": "fragment",
"toId": "concept_1nqriq",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_pqdkx3",
"type": "mentions",
"fromId": "frag_qhu1w0",
"fromType": "fragment",
"toId": "concept_jl0fx8",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 6
},
{
"id": "link_ibhsjx",
"type": "mentions",
"fromId": "frag_qhu1w0",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_smqahn",
"type": "mentions",
"fromId": "frag_qhu1w0",
"fromType": "fragment",
"toId": "concept_dh6z62",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_ibln84",
"type": "mentions",
"fromId": "frag_qhu1w0",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_rkj8nd",
"type": "mentions",
"fromId": "frag_qhu1w0",
"fromType": "fragment",
"toId": "concept_h0dhpz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_lfahv",
"type": "mentions",
"fromId": "frag_qhu1w0",
"fromType": "fragment",
"toId": "concept_1u3dy",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_4qw4lr",
"type": "mentions",
"fromId": "frag_3vvxiw",
"fromType": "fragment",
"toId": "concept_1j7mqk",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_4pldf0",
"type": "mentions",
"fromId": "frag_3vvxiw",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_2hfic9",
"type": "mentions",
"fromId": "frag_3vvxiw",
"fromType": "fragment",
"toId": "concept_6azi1v",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_4phiqt",
"type": "mentions",
"fromId": "frag_3vvxiw",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_6q54os",
"type": "mentions",
"fromId": "frag_3vvxiw",
"fromType": "fragment",
"toId": "concept_1u3dy",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_j4egvm",
"type": "mentions",
"fromId": "frag_3vvxiw",
"fromType": "fragment",
"toId": "concept_e91o2j",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_wrcf7r",
"type": "mentions",
"fromId": "frag_o5huex",
"fromType": "fragment",
"toId": "concept_6c0biv",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_amhcre",
"type": "mentions",
"fromId": "frag_o5huex",
"fromType": "fragment",
"toId": "concept_pmrgxq",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_un2yk1",
"type": "mentions",
"fromId": "frag_o5huex",
"fromType": "fragment",
"toId": "concept_1s9m88",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_bqls5h",
"type": "mentions",
"fromId": "frag_o5huex",
"fromType": "fragment",
"toId": "concept_nd5e82",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_ujebtz",
"type": "mentions",
"fromId": "frag_o5huex",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_uji6i6",
"type": "mentions",
"fromId": "frag_o5huex",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_1n7sjb",
"type": "mentions",
"fromId": "frag_yice37",
"fromType": "fragment",
"toId": "concept_6c0biv",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_h1o4f",
"type": "mentions",
"fromId": "frag_yice37",
"fromType": "fragment",
"toId": "concept_1s9m88",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_kqauh",
"type": "mentions",
"fromId": "frag_yice37",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_kmg6a",
"type": "mentions",
"fromId": "frag_yice37",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ydtwdj",
"type": "mentions",
"fromId": "frag_yice37",
"fromType": "fragment",
"toId": "concept_1u3dy",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_k20v6",
"type": "mentions",
"fromId": "frag_yice37",
"fromType": "fragment",
"toId": "concept_1lj7f1",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_4ct6x6",
"type": "mentions",
"fromId": "frag_qso8rl",
"fromType": "fragment",
"toId": "concept_1pb54r",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_6i10h2",
"type": "mentions",
"fromId": "frag_qso8rl",
"fromType": "fragment",
"toId": "concept_6c0biv",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_4drjtc",
"type": "mentions",
"fromId": "frag_qso8rl",
"fromType": "fragment",
"toId": "concept_1s9m88",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_4a2x3a",
"type": "mentions",
"fromId": "frag_qso8rl",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_4a6rrh",
"type": "mentions",
"fromId": "frag_qso8rl",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_dlsxva",
"type": "mentions",
"fromId": "frag_qso8rl",
"fromType": "fragment",
"toId": "concept_1u3dy",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_h9ddvu",
"type": "mentions",
"fromId": "frag_83cldn",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_aowd8u",
"type": "mentions",
"fromId": "frag_83cldn",
"fromType": "fragment",
"toId": "concept_llkge4",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_h9x9la",
"type": "mentions",
"fromId": "frag_83cldn",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_ivq2h9",
"type": "mentions",
"fromId": "frag_83cldn",
"fromType": "fragment",
"toId": "concept_1wtnv",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_9qp523",
"type": "mentions",
"fromId": "frag_83cldn",
"fromType": "fragment",
"toId": "concept_jlea8r",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_ivq6be",
"type": "mentions",
"fromId": "frag_83cldn",
"fromType": "fragment",
"toId": "concept_1y1ii",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_l1qqlg",
"type": "mentions",
"fromId": "frag_ou8eku",
"fromType": "fragment",
"toId": "concept_f09bfe",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_34xha7",
"type": "mentions",
"fromId": "frag_ou8eku",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_rl8v8h",
"type": "mentions",
"fromId": "frag_ou8eku",
"fromType": "fragment",
"toId": "concept_1xdsg",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_j6mvp3",
"type": "mentions",
"fromId": "frag_ou8eku",
"fromType": "fragment",
"toId": "concept_b0w3to",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_34hg8y",
"type": "mentions",
"fromId": "frag_ou8eku",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_34eykp",
"type": "mentions",
"fromId": "frag_ou8eku",
"fromType": "fragment",
"toId": "concept_1keqml",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_uiayqy",
"type": "mentions",
"fromId": "frag_mjx4px",
"fromType": "fragment",
"toId": "concept_2487m",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_3rvm3z",
"type": "mentions",
"fromId": "frag_mjx4px",
"fromType": "fragment",
"toId": "concept_gvydlk",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_lzgqhn",
"type": "mentions",
"fromId": "frag_mjx4px",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_lywus7",
"type": "mentions",
"fromId": "frag_mjx4px",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_ah0cc5",
"type": "mentions",
"fromId": "frag_mjx4px",
"fromType": "fragment",
"toId": "concept_24vs",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_k84ty6",
"type": "mentions",
"fromId": "frag_mjx4px",
"fromType": "fragment",
"toId": "concept_5amdjs",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_cty494",
"type": "mentions",
"fromId": "frag_xgccse",
"fromType": "fragment",
"toId": "concept_1u7gb",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_bee0fu",
"type": "mentions",
"fromId": "frag_xgccse",
"fromType": "fragment",
"toId": "concept_u4un1i",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_s93sit",
"type": "mentions",
"fromId": "frag_xgccse",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_s9jtk2",
"type": "mentions",
"fromId": "frag_xgccse",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_s9no89",
"type": "mentions",
"fromId": "frag_xgccse",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ilc1ko",
"type": "mentions",
"fromId": "frag_xgccse",
"fromType": "fragment",
"toId": "concept_f09bfe",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_i3umg2",
"type": "mentions",
"fromId": "frag_4t3zpb",
"fromType": "fragment",
"toId": "concept_2487m",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_i377kg",
"type": "mentions",
"fromId": "frag_4t3zpb",
"fromType": "fragment",
"toId": "concept_1vjle",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_x32n50",
"type": "mentions",
"fromId": "frag_4t3zpb",
"fromType": "fragment",
"toId": "concept_raj06o",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_7b1l0d",
"type": "mentions",
"fromId": "frag_4t3zpb",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_7blgpt",
"type": "mentions",
"fromId": "frag_4t3zpb",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_z8ql2n",
"type": "mentions",
"fromId": "frag_4t3zpb",
"fromType": "fragment",
"toId": "concept_o1b6i6",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_k51fms",
"type": "mentions",
"fromId": "frag_g3bxss",
"fromType": "fragment",
"toId": "concept_1m5vi9",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_i7m6y9",
"type": "mentions",
"fromId": "frag_g3bxss",
"fromType": "fragment",
"toId": "concept_vlhikl",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_k49vja",
"type": "mentions",
"fromId": "frag_g3bxss",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_k4pwkj",
"type": "mentions",
"fromId": "frag_g3bxss",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_k4tr8q",
"type": "mentions",
"fromId": "frag_g3bxss",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_lvls2r",
"type": "mentions",
"fromId": "frag_g3bxss",
"fromType": "fragment",
"toId": "concept_5amdjs",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_uf3de5",
"type": "mentions",
"fromId": "frag_g30jd6",
"fromType": "fragment",
"toId": "concept_iamaui",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 5
},
{
"id": "link_7v1imq",
"type": "mentions",
"fromId": "frag_g30jd6",
"fromType": "fragment",
"toId": "concept_9n44xh",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_41f6bg",
"type": "mentions",
"fromId": "frag_g30jd6",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_41z20w",
"type": "mentions",
"fromId": "frag_g30jd6",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_s83e73",
"type": "mentions",
"fromId": "frag_g30jd6",
"fromType": "fragment",
"toId": "concept_f09bfe",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_63r7js",
"type": "mentions",
"fromId": "frag_g30jd6",
"fromType": "fragment",
"toId": "concept_77o2i1",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_6gdclb",
"type": "mentions",
"fromId": "frag_h3k825",
"fromType": "fragment",
"toId": "concept_1pb5x5",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_6d705b",
"type": "mentions",
"fromId": "frag_h3k825",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_6dqvur",
"type": "mentions",
"fromId": "frag_h3k825",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_yuiw1f",
"type": "mentions",
"fromId": "frag_h3k825",
"fromType": "fragment",
"toId": "concept_o1b6i6",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_vbxzaf",
"type": "mentions",
"fromId": "frag_h3k825",
"fromType": "fragment",
"toId": "concept_wvtd0j",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_ujv80y",
"type": "mentions",
"fromId": "frag_h3k825",
"fromType": "fragment",
"toId": "concept_f09bfe",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_u202ao",
"type": "mentions",
"fromId": "frag_ivfanb",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 6
},
{
"id": "link_5vbuf1",
"type": "mentions",
"fromId": "frag_ivfanb",
"fromType": "fragment",
"toId": "concept_f09bfe",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 6
},
{
"id": "link_4bxc0w",
"type": "mentions",
"fromId": "frag_ivfanb",
"fromType": "fragment",
"toId": "concept_gv37qm",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_ly48ir",
"type": "mentions",
"fromId": "frag_ivfanb",
"fromType": "fragment",
"toId": "concept_1t24y",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_7nfh0u",
"type": "mentions",
"fromId": "frag_ivfanb",
"fromType": "fragment",
"toId": "concept_b6ebeu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_u1k19f",
"type": "mentions",
"fromId": "frag_ivfanb",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_1ikequ",
"type": "mentions",
"fromId": "frag_g1qshr",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_1i1w1c",
"type": "mentions",
"fromId": "frag_g1qshr",
"fromType": "fragment",
"toId": "concept_1keqml",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_mo62l4",
"type": "mentions",
"fromId": "frag_g1qshr",
"fromType": "fragment",
"toId": "concept_dlzsam",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_mo3t4t",
"type": "mentions",
"fromId": "frag_g1qshr",
"fromType": "fragment",
"toId": "concept_f09bfe",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_1i4dpl",
"type": "mentions",
"fromId": "frag_g1qshr",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_1i0j1e",
"type": "mentions",
"fromId": "frag_g1qshr",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_yh3xba",
"type": "mentions",
"fromId": "frag_m2z8gs",
"fromType": "fragment",
"toId": "concept_7wml4k",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_xixeog",
"type": "mentions",
"fromId": "frag_m2z8gs",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_xizth4",
"type": "mentions",
"fromId": "frag_m2z8gs",
"fromType": "fragment",
"toId": "concept_1keo3c",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_8yr8wd",
"type": "mentions",
"fromId": "frag_m2z8gs",
"fromType": "fragment",
"toId": "concept_mu6b4o",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ycjik0",
"type": "mentions",
"fromId": "frag_m2z8gs",
"fromType": "fragment",
"toId": "concept_8b8yt5",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_dogahu",
"type": "mentions",
"fromId": "frag_m2z8gs",
"fromType": "fragment",
"toId": "concept_e7b4a7",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_texq5s",
"type": "mentions",
"fromId": "frag_la8iqs",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 6
},
{
"id": "link_tfhlv8",
"type": "mentions",
"fromId": "frag_la8iqs",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_qk2x0k",
"type": "mentions",
"fromId": "frag_la8iqs",
"fromType": "fragment",
"toId": "concept_7liuz8",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_uq9yjr",
"type": "mentions",
"fromId": "frag_la8iqs",
"fromType": "fragment",
"toId": "concept_1wtnv",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_2ev77v",
"type": "mentions",
"fromId": "frag_la8iqs",
"fromType": "fragment",
"toId": "concept_jlea8r",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_uqa2dw",
"type": "mentions",
"fromId": "frag_la8iqs",
"fromType": "fragment",
"toId": "concept_1y1ii",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_nyigeg",
"type": "mentions",
"fromId": "frag_k2wcc",
"fromType": "fragment",
"toId": "concept_1zybu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_6zabo1",
"type": "mentions",
"fromId": "frag_k2wcc",
"fromType": "fragment",
"toId": "concept_i3xxga",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_vu9w1p",
"type": "mentions",
"fromId": "frag_k2wcc",
"fromType": "fragment",
"toId": "concept_3bbkvv",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_wnffzd",
"type": "mentions",
"fromId": "frag_k2wcc",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_nzaezu",
"type": "mentions",
"fromId": "frag_k2wcc",
"fromType": "fragment",
"toId": "concept_20b63",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_wnblb6",
"type": "mentions",
"fromId": "frag_k2wcc",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_x38ek6",
"type": "mentions",
"fromId": "frag_amqxax",
"fromType": "fragment",
"toId": "concept_2hzo",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_m39vrf",
"type": "mentions",
"fromId": "frag_amqxax",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_m3yuge",
"type": "mentions",
"fromId": "frag_amqxax",
"fromType": "fragment",
"toId": "concept_1liwnt",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_mwffp3",
"type": "mentions",
"fromId": "frag_amqxax",
"fromType": "fragment",
"toId": "concept_3bbkvv",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_osgc5n",
"type": "mentions",
"fromId": "frag_amqxax",
"fromType": "fragment",
"toId": "concept_dkfr25",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_x39j57",
"type": "mentions",
"fromId": "frag_amqxax",
"fromType": "fragment",
"toId": "concept_22cy",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_rktrqw",
"type": "mentions",
"fromId": "frag_k3j5ot",
"fromType": "fragment",
"toId": "concept_25hr2",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_2wd36k",
"type": "mentions",
"fromId": "frag_k3j5ot",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_vcpp15",
"type": "mentions",
"fromId": "frag_k3j5ot",
"fromType": "fragment",
"toId": "concept_mmi027",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_2wt47t",
"type": "mentions",
"fromId": "frag_k3j5ot",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_2wwyw0",
"type": "mentions",
"fromId": "frag_k3j5ot",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_2xi2ws",
"type": "mentions",
"fromId": "frag_k3j5ot",
"fromType": "fragment",
"toId": "concept_1liwnt",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_w1zyo6",
"type": "mentions",
"fromId": "frag_oxk0al",
"fromType": "fragment",
"toId": "concept_ljky0p",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_uk9tet",
"type": "mentions",
"fromId": "frag_oxk0al",
"fromType": "fragment",
"toId": "concept_q1bzdy",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_y0xfwi",
"type": "mentions",
"fromId": "frag_oxk0al",
"fromType": "fragment",
"toId": "concept_248bx",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_b1dpj1",
"type": "mentions",
"fromId": "frag_oxk0al",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_b1tqka",
"type": "mentions",
"fromId": "frag_oxk0al",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_b1xl8h",
"type": "mentions",
"fromId": "frag_oxk0al",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_mpfcly",
"type": "mentions",
"fromId": "frag_w7ug9l",
"fromType": "fragment",
"toId": "concept_1hqksr",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_gs8fg9",
"type": "mentions",
"fromId": "frag_w7ug9l",
"fromType": "fragment",
"toId": "concept_20b63",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_2zc842",
"type": "mentions",
"fromId": "frag_w7ug9l",
"fromType": "fragment",
"toId": "concept_gpo83y",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_137lb5",
"type": "mentions",
"fromId": "frag_w7ug9l",
"fromType": "fragment",
"toId": "concept_cpps5l",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_mo201m",
"type": "mentions",
"fromId": "frag_w7ug9l",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_30349q",
"type": "mentions",
"fromId": "frag_w7ug9l",
"fromType": "fragment",
"toId": "concept_i3xxga",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_e926ua",
"type": "mentions",
"fromId": "frag_h1uzlu",
"fromType": "fragment",
"toId": "concept_w9ow8",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_grp8co",
"type": "mentions",
"fromId": "frag_h1uzlu",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_grmtk0",
"type": "mentions",
"fromId": "frag_h1uzlu",
"fromType": "fragment",
"toId": "concept_1keo3c",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_brq61n",
"type": "mentions",
"fromId": "frag_h1uzlu",
"fromType": "fragment",
"toId": "concept_mu6b4o",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_7214g6",
"type": "mentions",
"fromId": "frag_h1uzlu",
"fromType": "fragment",
"toId": "concept_e7b4a7",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_qezy30",
"type": "mentions",
"fromId": "frag_rrcn8h",
"fromType": "fragment",
"toId": "concept_gvydl6",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_ivbn1f",
"type": "mentions",
"fromId": "frag_rrcn8h",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_iz6j6s",
"type": "mentions",
"fromId": "frag_rrcn8h",
"fromType": "fragment",
"toId": "concept_1rhfuj",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_smf4sp",
"type": "mentions",
"fromId": "frag_rrcn8h",
"fromType": "fragment",
"toId": "concept_cbkjnw",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_iurrbz",
"type": "mentions",
"fromId": "frag_rrcn8h",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_qz3qo9",
"type": "mentions",
"fromId": "frag_rrcn8h",
"fromType": "fragment",
"toId": "concept_fqi63b",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_er113i",
"type": "mentions",
"fromId": "frag_z6ich8",
"fromType": "fragment",
"toId": "concept_hkekis",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_67yaa7",
"type": "mentions",
"fromId": "frag_z6ich8",
"fromType": "fragment",
"toId": "concept_22cy",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_baijq1",
"type": "mentions",
"fromId": "frag_z6ich8",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_k9jq2f",
"type": "mentions",
"fromId": "frag_z6ich8",
"fromType": "fragment",
"toId": "concept_1zoco",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_bd77ix",
"type": "mentions",
"fromId": "frag_z6ich8",
"fromType": "fragment",
"toId": "concept_1fehth",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ewaxlk",
"type": "mentions",
"fromId": "frag_z6ich8",
"fromType": "fragment",
"toId": "concept_j6wxa1",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_9z80vh",
"type": "mentions",
"fromId": "frag_5i1o5v",
"fromType": "fragment",
"toId": "concept_pmrgxq",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_e711pf",
"type": "mentions",
"fromId": "frag_5i1o5v",
"fromType": "fragment",
"toId": "concept_gr1mao",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_465x9j",
"type": "mentions",
"fromId": "frag_5i1o5v",
"fromType": "fragment",
"toId": "concept_24vs",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_frkfbo",
"type": "mentions",
"fromId": "frag_5i1o5v",
"fromType": "fragment",
"toId": "concept_dh6z62",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_9k31og",
"type": "mentions",
"fromId": "frag_5i1o5v",
"fromType": "fragment",
"toId": "concept_qjcwf1",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_v8l7ny",
"type": "mentions",
"fromId": "frag_5i1o5v",
"fromType": "fragment",
"toId": "concept_1nqriq",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_wh3yx2",
"type": "mentions",
"fromId": "frag_1wv3le",
"fromType": "fragment",
"toId": "concept_ofkd9r",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_99sgv1",
"type": "mentions",
"fromId": "frag_1wv3le",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_9al3lt",
"type": "mentions",
"fromId": "frag_1wv3le",
"fromType": "fragment",
"toId": "concept_1loq3f",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_xd9957",
"type": "mentions",
"fromId": "frag_1wv3le",
"fromType": "fragment",
"toId": "concept_dgvlef",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_vw37qc",
"type": "mentions",
"fromId": "frag_1wv3le",
"fromType": "fragment",
"toId": "concept_pmrgxq",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_9dw0bj",
"type": "mentions",
"fromId": "frag_1wv3le",
"fromType": "fragment",
"toId": "concept_1t1x1u",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_nj3qtq",
"type": "mentions",
"fromId": "frag_jx95go",
"fromType": "fragment",
"toId": "concept_xr4ppa",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_io6bw3",
"type": "mentions",
"fromId": "frag_jx95go",
"fromType": "fragment",
"toId": "concept_nhnbu3",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_a655yl",
"type": "mentions",
"fromId": "frag_jx95go",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_elexuy",
"type": "mentions",
"fromId": "frag_jx95go",
"fromType": "fragment",
"toId": "concept_etr8bp",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_dz8o3h",
"type": "mentions",
"fromId": "frag_jx95go",
"fromType": "fragment",
"toId": "concept_dkfr25",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_a6n0x0",
"type": "mentions",
"fromId": "frag_jx95go",
"fromType": "fragment",
"toId": "concept_1jf909",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ae3a56",
"type": "mentions",
"fromId": "frag_mb8wqq",
"fromType": "fragment",
"toId": "concept_htl1p0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_c4qnhz",
"type": "mentions",
"fromId": "frag_mb8wqq",
"fromType": "fragment",
"toId": "concept_ljhtmq",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 4
},
{
"id": "link_fshq17",
"type": "mentions",
"fromId": "frag_mb8wqq",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_akrhoa",
"type": "mentions",
"fromId": "frag_mb8wqq",
"fromType": "fragment",
"toId": "concept_iap7oa",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_fszkzm",
"type": "mentions",
"fromId": "frag_mb8wqq",
"fromType": "fragment",
"toId": "concept_1jf909",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_foe6kp",
"type": "mentions",
"fromId": "frag_mb8wqq",
"fromType": "fragment",
"toId": "concept_1t1x1u",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_m0kasq",
"type": "mentions",
"fromId": "frag_c2gwcq",
"fromType": "fragment",
"toId": "concept_ihtre0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_3ejrm9",
"type": "mentions",
"fromId": "frag_c2gwcq",
"fromType": "fragment",
"toId": "concept_54l41w",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_4h39in",
"type": "mentions",
"fromId": "frag_c2gwcq",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_4gn8he",
"type": "mentions",
"fromId": "frag_c2gwcq",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_4gjdt7",
"type": "mentions",
"fromId": "frag_c2gwcq",
"fromType": "fragment",
"toId": "concept_1kguoz",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_28hden",
"type": "mentions",
"fromId": "frag_c2gwcq",
"fromType": "fragment",
"toId": "concept_6azi1v",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_mn7g18",
"type": "mentions",
"fromId": "frag_lybfjt",
"fromType": "fragment",
"toId": "concept_jw39c4",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 6
},
{
"id": "link_maooot",
"type": "mentions",
"fromId": "frag_lybfjt",
"fromType": "fragment",
"toId": "concept_54l41w",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_p0yqbx",
"type": "mentions",
"fromId": "frag_lybfjt",
"fromType": "fragment",
"toId": "concept_etr8bp",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_l8l7to",
"type": "mentions",
"fromId": "frag_lybfjt",
"fromType": "fragment",
"toId": "concept_1kc6q0",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_pn503e",
"type": "mentions",
"fromId": "frag_lybfjt",
"fromType": "fragment",
"toId": "concept_dkfr25",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_l856sf",
"type": "mentions",
"fromId": "frag_lybfjt",
"fromType": "fragment",
"toId": "concept_1ji3iu",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ylws04",
"type": "mentions",
"fromId": "frag_4nve2i",
"fromType": "fragment",
"toId": "concept_fqi63b",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_ejrf1l",
"type": "mentions",
"fromId": "frag_4nve2i",
"fromType": "fragment",
"toId": "concept_2atg",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 3
},
{
"id": "link_cab5of",
"type": "mentions",
"fromId": "frag_4nve2i",
"fromType": "fragment",
"toId": "concept_54a26p",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_yrio8h",
"type": "mentions",
"fromId": "frag_4nve2i",
"fromType": "fragment",
"toId": "concept_fgc06b",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_f7q17i",
"type": "mentions",
"fromId": "frag_4nve2i",
"fromType": "fragment",
"toId": "concept_9ww6vb",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ou4ny5",
"type": "mentions",
"fromId": "frag_4nve2i",
"fromType": "fragment",
"toId": "concept_1usl5",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_u09vol",
"type": "mentions",
"fromId": "frag_h74lv",
"fromType": "fragment",
"toId": "concept_25ph",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_uuc6f",
"type": "mentions",
"fromId": "frag_h74lv",
"fromType": "fragment",
"toId": "concept_1lmfpk",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 2
},
{
"id": "link_wd2xa",
"type": "mentions",
"fromId": "frag_h74lv",
"fromType": "fragment",
"toId": "concept_1k80xn",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_ob70oh",
"type": "mentions",
"fromId": "frag_h74lv",
"fromType": "fragment",
"toId": "concept_fqi63b",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_6ulya2",
"type": "mentions",
"fromId": "frag_h74lv",
"fromType": "fragment",
"toId": "concept_1v19a",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_6awdw",
"type": "mentions",
"fromId": "frag_h74lv",
"fromType": "fragment",
"toId": "concept_54a26p",
"toType": "concept",
"documentId": "doc_6bb52r",
"score": 1
},
{
"id": "link_5fyk8w",
"type": "adjacent",
"fromId": "frag_d3g9ts",
"fromType": "fragment",
"toId": "frag_pmaij6",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_hn8tmc",
"type": "adjacent",
"fromId": "frag_pmaij6",
"fromType": "fragment",
"toId": "frag_dw2gxf",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_ppo0qr",
"type": "adjacent",
"fromId": "frag_dw2gxf",
"fromType": "fragment",
"toId": "frag_1qvdbx",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_hnzag",
"type": "adjacent",
"fromId": "frag_1qvdbx",
"fromType": "fragment",
"toId": "frag_2pvrfi",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_w05mpp",
"type": "adjacent",
"fromId": "frag_2pvrfi",
"fromType": "fragment",
"toId": "frag_49spg6",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_3zquph",
"type": "adjacent",
"fromId": "frag_49spg6",
"fromType": "fragment",
"toId": "frag_l7f27m",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_tt6jkz",
"type": "adjacent",
"fromId": "frag_l7f27m",
"fromType": "fragment",
"toId": "frag_4yjic",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_lu2bze",
"type": "adjacent",
"fromId": "frag_4yjic",
"fromType": "fragment",
"toId": "frag_qhu1w0",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_83dkye",
"type": "adjacent",
"fromId": "frag_qhu1w0",
"fromType": "fragment",
"toId": "frag_3vvxiw",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_34uo8y",
"type": "adjacent",
"fromId": "frag_3vvxiw",
"fromType": "fragment",
"toId": "frag_o5huex",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_bi2hj5",
"type": "adjacent",
"fromId": "frag_o5huex",
"fromType": "fragment",
"toId": "frag_yice37",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_cizpxa",
"type": "adjacent",
"fromId": "frag_yice37",
"fromType": "fragment",
"toId": "frag_qso8rl",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_2reo2g",
"type": "adjacent",
"fromId": "frag_qso8rl",
"fromType": "fragment",
"toId": "frag_83cldn",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_qggyam",
"type": "adjacent",
"fromId": "frag_83cldn",
"fromType": "fragment",
"toId": "frag_ou8eku",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_9rnzir",
"type": "adjacent",
"fromId": "frag_ou8eku",
"fromType": "fragment",
"toId": "frag_mjx4px",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_w0ls9d",
"type": "adjacent",
"fromId": "frag_mjx4px",
"fromType": "fragment",
"toId": "frag_xgccse",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_k1gx09",
"type": "adjacent",
"fromId": "frag_xgccse",
"fromType": "fragment",
"toId": "frag_4t3zpb",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_bi4qq0",
"type": "adjacent",
"fromId": "frag_4t3zpb",
"fromType": "fragment",
"toId": "frag_g3bxss",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_303yz",
"type": "adjacent",
"fromId": "frag_g3bxss",
"fromType": "fragment",
"toId": "frag_g30jd6",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_c10pda",
"type": "adjacent",
"fromId": "frag_g30jd6",
"fromType": "fragment",
"toId": "frag_h3k825",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_havre8",
"type": "adjacent",
"fromId": "frag_h3k825",
"fromType": "fragment",
"toId": "frag_ivfanb",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_kqof2h",
"type": "adjacent",
"fromId": "frag_ivfanb",
"fromType": "fragment",
"toId": "frag_g1qshr",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_5qkyvc",
"type": "adjacent",
"fromId": "frag_g1qshr",
"fromType": "fragment",
"toId": "frag_m2z8gs",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_3pley0",
"type": "adjacent",
"fromId": "frag_m2z8gs",
"fromType": "fragment",
"toId": "frag_la8iqs",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_porisp",
"type": "adjacent",
"fromId": "frag_la8iqs",
"fromType": "fragment",
"toId": "frag_k2wcc",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_ehpuil",
"type": "adjacent",
"fromId": "frag_k2wcc",
"fromType": "fragment",
"toId": "frag_amqxax",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_v7bo83",
"type": "adjacent",
"fromId": "frag_amqxax",
"fromType": "fragment",
"toId": "frag_k3j5ot",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_hc4hkg",
"type": "adjacent",
"fromId": "frag_k3j5ot",
"fromType": "fragment",
"toId": "frag_oxk0al",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_tj8mbf",
"type": "adjacent",
"fromId": "frag_oxk0al",
"fromType": "fragment",
"toId": "frag_w7ug9l",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_sprb4x",
"type": "adjacent",
"fromId": "frag_w7ug9l",
"fromType": "fragment",
"toId": "frag_h1uzlu",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_t55ser",
"type": "adjacent",
"fromId": "frag_h1uzlu",
"fromType": "fragment",
"toId": "frag_rrcn8h",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_udj8qi",
"type": "adjacent",
"fromId": "frag_rrcn8h",
"fromType": "fragment",
"toId": "frag_z6ich8",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_26cgxo",
"type": "adjacent",
"fromId": "frag_z6ich8",
"fromType": "fragment",
"toId": "frag_5i1o5v",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_wxy4ag",
"type": "adjacent",
"fromId": "frag_5i1o5v",
"fromType": "fragment",
"toId": "frag_1wv3le",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_va27i3",
"type": "adjacent",
"fromId": "frag_1wv3le",
"fromType": "fragment",
"toId": "frag_jx95go",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_i40hvz",
"type": "adjacent",
"fromId": "frag_jx95go",
"fromType": "fragment",
"toId": "frag_mb8wqq",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_lb7pns",
"type": "adjacent",
"fromId": "frag_mb8wqq",
"fromType": "fragment",
"toId": "frag_c2gwcq",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_qzbzwd",
"type": "adjacent",
"fromId": "frag_c2gwcq",
"fromType": "fragment",
"toId": "frag_lybfjt",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_lep7w4",
"type": "adjacent",
"fromId": "frag_lybfjt",
"fromType": "fragment",
"toId": "frag_4nve2i",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
},
{
"id": "link_fg0u0w",
"type": "adjacent",
"fromId": "frag_4nve2i",
"fromType": "fragment",
"toId": "frag_h74lv",
"toType": "fragment",
"documentId": "doc_6bb52r",
"score": 0.4
}
]
}
FILE:dist/embedding-providers/EmbeddingProvider.js
/**
* EmbeddingProvider Interface
* 所有 embedding provider 必须实现此接口契约。
*/
class EmbeddingProvider {
/**
* 将文本列表转为向量
* @param {string[]} texts
* @returns {Promise<number[][]>}
*/
async embed(texts) {
throw new Error('Not implemented');
}
/**
* 返回 provider 名称
*/
get name() {
return 'unknown';
}
/**
* 返回向量维度
*/
get dimension() {
throw new Error('Not implemented');
}
}
module.exports = { EmbeddingProvider };
FILE:dist/embedding-providers/MockProvider.js
/**
* MockProvider
* 返回随机向量,用于测试和离线开发。
*/
const { EmbeddingProvider } = require('./EmbeddingProvider');
const DEFAULT_DIM = 1536;
class MockProvider extends EmbeddingProvider {
constructor({ dimension = DEFAULT_DIM, seed = 42 } = {}) {
super();
this._dim = dimension;
this._seed = seed;
this._cache = new Map();
}
get name() {
return 'mock';
}
get dimension() {
return this._dim;
}
_pseudoRandom(text) {
let hash = 0;
for (let i = 0; i < text.length; i += 1) {
hash = ((hash << 5) - hash + text.charCodeAt(i)) | 0;
}
return Math.abs(hash) / 0x7fffffff;
}
async embed(texts) {
return texts.map((text) => {
if (this._cache.has(text)) return this._cache.get(text);
const vec = [];
for (let i = 0; i < this._dim; i += 1) {
// deterministic random based on text + index
const base = this._pseudoRandom(text + i);
vec.push(base);
}
// L2 normalize
const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0));
const normalized = norm > 0 ? vec.map((v) => v / norm) : vec;
this._cache.set(text, normalized);
return normalized;
});
}
}
module.exports = { MockProvider };
FILE:dist/embedding-providers/OpenAICompatibleProvider.js
/**
* OpenAICompatibleProvider
* 调用 OpenAI-compatible API endpoint(如 vLLM、Ollama、Azure OpenAI 等)。
*/
const { EmbeddingProvider } = require('./EmbeddingProvider');
const DEFAULT_DIM = 1536;
class OpenAICompatibleProvider extends EmbeddingProvider {
constructor({ baseURL = 'https://api.openai.com/v1', apiKey = '', model = 'text-embedding-3-small', dimension = DEFAULT_DIM, batchSize = 100 } = {}) {
super();
this._baseURL = baseURL.replace(/\/$/, '');
this._apiKey = apiKey;
this._model = model;
this._dim = dimension;
this._batchSize = batchSize;
}
get name() {
return `openai-compatible:this._model`;
}
get dimension() {
return this._dim;
}
async embed(texts) {
if (!texts || texts.length === 0) return [];
const results = [];
for (let i = 0; i < texts.length; i += this._batchSize) {
const batch = texts.slice(i, i + this._batchSize);
const vectors = await this._fetchBatch(batch);
results.push(...vectors);
}
return results;
}
async _fetchBatch(batch) {
const url = `this._baseURL/embeddings`;
const body = {
model: this._model,
input: batch
};
const headers = {
'Content-Type': 'application/json'
};
if (this._apiKey) {
headers['Authorization'] = `Bearer this._apiKey`;
}
const resp = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body)
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
throw new Error(`Embedding API error resp.status: text`);
}
const json = await resp.json();
if (!json.data || !Array.isArray(json.data)) {
throw new Error(`Unexpected embedding response format`);
}
// sort by index to maintain order
const sorted = json.data.slice().sort((a, b) => a.index - b.index);
return sorted.map((item) => {
if (!item.embedding || !Array.isArray(item.embedding)) {
throw new Error(`Invalid embedding vector in response`);
}
return item.embedding;
});
}
}
module.exports = { OpenAICompatibleProvider };
FILE:dist/embedding-providers/index.js
/**
* embedding-providers/index.js
* Factory: 根据配置返回对应的 embedding provider 实例。
*/
const { MockProvider } = require('./MockProvider');
const { OpenAICompatibleProvider } = require('./OpenAICompatibleProvider');
function createEmbeddingProvider(type = 'mock', options = {}) {
switch (type) {
case 'mock':
return new MockProvider(options);
case 'openai':
return new OpenAICompatibleProvider(options);
default:
throw new Error(`Unknown embedding provider type: type. Use 'mock' or 'openai'.`);
}
}
module.exports = { createEmbeddingProvider, MockProvider, OpenAICompatibleProvider };
FILE:dist/index.js
#!/usr/bin/env node
/**
* LinkMind CLI - 知识连接引擎
* 支持 adapter 切换(json/sqlite)和 embedding 切换(keyword/openai)
*/
const fs = require('fs');
const path = require('path');
const THIS_DIR = __dirname.includes(`path.sepdist`) ? __dirname : __dirname;
const IS_DIST = THIS_DIR.includes(`path.sepdist`);
const ROOT = IS_DIST ? path.resolve(THIS_DIR, '..') : path.resolve(THIS_DIR, '..');
const DATA_DIR = path.join(ROOT, 'data');
function createEmptyDb() {
return {
version: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
documents: [],
fragments: [],
concepts: [],
links: []
};
}
function stableId(prefix, input) {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
}
return `prefix_Math.abs(hash).toString(36)`;
}
function normalizeConcept(text) {
return text
.toLowerCase()
.replace(/[\u2018\u2019'"""''()()【】\[\],.:;!?/\\]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
const STOPWORDS = new Set([
'the','and','for','that','this','with','from','into','your','have','will','not','are','was','were',
'can','could','should','about','what','when','where','which','while','than','then','them','they',
'their','there','here','also','more','most','some','such','using','used','use','make','made',
'over','under','very','just','only','each','been','being','does','did','done','how','why',
'our','you','its','his','her','she','him','who','has','had','but','too','via','per',
'one','two','three',
'我们','你们','他们','这个','那个','一个','一种','可以','需要','如果','因为','所以','以及',
'进行','通过','没有','不是','就是','如何','什么','为什么','时候','今天','已经','还有',
'对于','一些','这种','那些','并且','或者','主要','用户','系统','功能','能力','作为'
]);
function extractConcepts(text) {
const zhMatches = text.match(/[\u4e00-\u9fa5]{2,8}/g) || [];
const enMatches = text.match(/[A-Za-z][A-Za-z0-9_-]{2,}/g) || [];
const tokens = [...zhMatches, ...enMatches]
.map((item) => item.trim())
.map((item) => ({ raw: item, normalized: normalizeConcept(item) }))
.filter((item) => item.normalized && !STOPWORDS.has(item.normalized));
const counts = new Map();
for (const token of tokens) {
counts.set(token.normalized, (counts.get(token.normalized) || 0) + 1);
}
return [...counts.entries()]
.map(([normalizedName, count]) => ({
normalizedName,
name: tokens.find((item) => item.normalized === normalizedName)?.raw || normalizedName,
count
}))
.sort((a, b) => b.count - a.count || a.normalizedName.localeCompare(b.normalizedName))
.slice(0, 12);
}
function splitParagraphs(text) {
return text
.split(/\n\s*\n+/)
.map((part) => part.trim())
.filter(Boolean);
}
function upsertDocument(db, doc) {
const existingIndex = db.documents.findIndex((item) => item.id === doc.id);
if (existingIndex >= 0) db.documents[existingIndex] = doc;
else db.documents.push(doc);
}
function buildForDocument(db, document) {
const fragmentIds = db.fragments.filter((f) => f.documentId === document.id).map((f) => f.id);
db.fragments = db.fragments.filter((f) => f.documentId !== document.id);
db.links = db.links.filter((l) => !fragmentIds.includes(l.fromId) && !fragmentIds.includes(l.toId) && l.documentId !== document.id);
const usedConceptIds = new Set();
for (const link of db.links) {
if (link.toType === 'concept') usedConceptIds.add(link.toId);
if (link.fromType === 'concept') usedConceptIds.add(link.fromId);
}
db.concepts = db.concepts.filter((c) => usedConceptIds.has(c.id));
const fragments = splitParagraphs(document.text).map((text, index) => ({
id: stableId('frag', `document.id:index:text.slice(0, 80)`),
type: 'fragment',
documentId: document.id,
index,
text,
summary: text.slice(0, 100),
conceptNames: []
}));
const conceptMap = new Map(db.concepts.map((c) => [c.normalizedName, c]));
const links = [];
for (const fragment of fragments) {
const fragmentConcepts = extractConcepts(fragment.text).slice(0, 6);
fragment.conceptNames = fragmentConcepts.map((item) => item.normalizedName);
for (const concept of fragmentConcepts) {
if (!conceptMap.has(concept.normalizedName)) {
conceptMap.set(concept.normalizedName, {
id: stableId('concept', concept.normalizedName),
type: 'concept',
name: concept.name,
normalizedName: concept.normalizedName,
salience: Math.min(1, concept.count / 3)
});
}
const conceptNode = conceptMap.get(concept.normalizedName);
links.push({
id: stableId('link', `fragment.id->conceptNode.id`),
type: 'mentions',
fromId: fragment.id,
fromType: 'fragment',
toId: conceptNode.id,
toType: 'concept',
documentId: document.id,
score: concept.count
});
}
}
for (let i = 0; i < fragments.length - 1; i += 1) {
links.push({
id: stableId('link', `fragments[i].id->fragments[i + 1].id`),
type: 'adjacent',
fromId: fragments[i].id,
fromType: 'fragment',
toId: fragments[i + 1].id,
toType: 'fragment',
documentId: document.id,
score: 0.4
});
}
db.fragments.push(...fragments);
db.links.push(...links);
db.concepts = [...conceptMap.values()].sort((a, b) => a.normalizedName.localeCompare(b.normalizedName));
return { fragmentsCreated: fragments.length, linksCreated: links.length, conceptsTotal: db.concepts.length };
}
async function queryWithEmbedding(adapter, embeddingProvider, q, options = {}) {
const db = await adapter.load();
const query = normalizeConcept(q || '');
if (!query) throw new Error('Query is required');
const terms = query.split(' ').filter(Boolean);
let scored = db.fragments.map((fragment) => {
const textNorm = normalizeConcept(fragment.text);
let score = 0;
for (const term of terms) {
if (textNorm.includes(term)) score += 3;
if (fragment.conceptNames.includes(term)) score += 5;
}
return { fragment, score };
}).filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.fragment.index - b.fragment.index)
.slice(0, Number(options.limit || 3));
// Vector re-rank if real embedding provider (not KeywordEmbeddingProvider)
if (embeddingProvider && embeddingProvider.constructor.name !== 'KeywordEmbeddingProvider') {
try {
const queryVec = await embeddingProvider.embed(q);
const fragTexts = scored.length > 0 ? scored.map((s) => s.fragment.text) : db.fragments.slice(0, 20).map((f) => f.text);
const fragVecs = await embeddingProvider.embedBatch(fragTexts);
if (fragVecs.length > 0) {
const scoredWithVec = scored.length > 0
? scored.map((s, i) => ({ ...s, vecScore: embeddingProvider.similarity(queryVec, fragVecs[i]) }))
: db.fragments.slice(0, 20).map((f, i) => ({ fragment: f, score: 0, vecScore: embeddingProvider.similarity(queryVec, fragVecs[i]) }));
scoredWithVec.forEach((item) => {
item.blendedScore = item.score * 0.6 + item.vecScore * 10 * 0.4;
});
scored = scoredWithVec.sort((a, b) => b.blendedScore - a.blendedScore).slice(0, Number(options.limit || 3));
}
} catch {
// No API key or vector search failed, keep keyword results
}
}
const evidence = scored.map((item) => {
const doc = db.documents.find((d) => d.id === item.fragment.documentId);
return {
fragmentId: item.fragment.id,
documentId: item.fragment.documentId,
documentTitle: doc?.title || 'unknown',
score: item.score || item.vecScore,
text: item.fragment.text
};
});
const relatedConcepts = [...new Set(scored.flatMap((item) => item.fragment.conceptNames))]
.map((name) => db.concepts.find((c) => c.normalizedName === name))
.filter(Boolean)
.slice(0, 8)
.map((concept) => ({ id: concept.id, name: concept.name, normalizedName: concept.normalizedName }));
const answer = evidence.length
? `Found evidence.length relevant fragments for "q". Top evidence comes from [...new Set(evidence.map((item) => item.documentTitle))].join(', ').`
: `No strong match found for "q". Try a broader concept or ingest more documents.`;
return {
query: q,
answer,
evidence,
relatedConcepts,
stats: { documents: db.documents.length, fragments: db.fragments.length, concepts: db.concepts.length, links: db.links.length }
};
}
function parseArgs(argv) {
const result = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (!token.startsWith('--')) {
result._.push(token);
continue;
}
const [key, inline] = token.slice(2).split('=');
if (inline !== undefined) {
result[key] = inline;
continue;
}
const next = argv[i + 1];
if (!next || next.startsWith('--')) {
result[key] = true;
} else {
result[key] = next;
i += 1;
}
}
return result;
}
function printHelp() {
console.log(`LinkMind MVP CLI v0.2.0
Commands:
ingest --file <path> [--title <title>] [--sourceType <type>] [--storage json|sqlite]
query --q <text> [--limit <n>] [--embedding keyword|openai]
status [--storage json|sqlite]
reset [--storage json|sqlite]
help
Options:
--storage <adapter> Storage: json (default) or sqlite
--embedding <provider> Embedding: keyword (default) or openai
--db-path <path> Custom db path
Examples:
node dist/index.js ingest --file examples/sample-note.md --title "Sample"
node dist/index.js query --q "knowledge connector"
node dist/index.js status --storage sqlite
node dist/index.js reset --storage sqlite`);
}
async function main() {
const [, , command, ...rest] = process.argv;
const args = parseArgs(rest);
const storageType = args.storage || 'json';
const embeddingType = args.embedding || 'keyword';
const dbPathArg = args['db-path'] || undefined;
let adapter = null;
try {
if (!command || command === 'help' || args.help) {
printHelp();
return;
}
try {
const adaptersDir = IS_DIST ? 'storage-adapters' : 'src/storage-adapters';
if (storageType === 'sqlite') {
const { SqliteStorageAdapter } = require(path.join(THIS_DIR, adaptersDir, 'SqliteStorageAdapter.js'));
adapter = new SqliteStorageAdapter(dbPathArg ? { dbPath: dbPathArg } : {});
} else {
const { JsonStorageAdapter } = require(path.join(THIS_DIR, adaptersDir, 'JsonStorageAdapter.js'));
adapter = new JsonStorageAdapter(dbPathArg ? { dbPath: dbPathArg } : {});
}
} catch (e) {
if (e.message.includes('sqlite3 not installed')) {
console.error('[LinkMind] sqlite3 not installed. Run: npm install sqlite3');
} else {
console.error(`[LinkMind] Adapter load error: e.message`);
}
process.exitCode = 1;
return;
}
let embeddingProvider = null;
try {
const providersDir = IS_DIST ? 'embedding-providers' : 'src/embedding-providers';
const { MockProvider, OpenAICompatibleProvider } = require(path.join(THIS_DIR, providersDir, 'index.js'));
embeddingProvider = embeddingType === 'openai'
? new OpenAICompatibleProvider({ baseURL: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', apiKey: process.env.OPENAI_API_KEY || '', model: process.env.OPENAI_MODEL || 'text-embedding-3-small' })
: new MockProvider();
} catch {
embeddingProvider = null;
}
const now = () => new Date().toISOString();
if (command === 'ingest') {
if (!args.file) throw new Error('--file is required for ingest');
const full = path.resolve(process.cwd(), args.file);
if (!fs.existsSync(full)) throw new Error(`File not found: full`);
const text = fs.readFileSync(full, 'utf8');
const db = await adapter.load();
const doc = {
id: stableId('doc', `full:args.title || path.basename(full)`),
type: 'document',
title: args.title || path.basename(full),
sourceType: args.sourceType || 'file',
sourceUri: full,
importedAt: now(),
status: 'active',
text
};
upsertDocument(db, doc);
const stats = buildForDocument(db, doc);
await adapter.save(db);
console.log(JSON.stringify({ documentId: doc.id, title: doc.title, ...stats }, null, 2));
return;
}
if (command === 'query') {
if (!args.q && !args.query) throw new Error('--q is required for query');
const result = await queryWithEmbedding(adapter, embeddingProvider, args.q || args.query, { limit: args.limit });
console.log(JSON.stringify(result, null, 2));
return;
}
if (command === 'status') {
const db = await adapter.load();
console.log(JSON.stringify({
documents: db.documents.length,
fragments: db.fragments.length,
concepts: db.concepts.length,
links: db.links.length,
updatedAt: db.updatedAt,
adapter: storageType,
embedding: embeddingType
}, null, 2));
return;
}
if (command === 'reset') {
await adapter.clear();
console.log(JSON.stringify({ ok: true, adapter: storageType }, null, 2));
return;
}
throw new Error(`Unknown command: command`);
} catch (error) {
console.error(`[LinkMind] error.message`);
process.exitCode = 1;
} finally {
if (adapter) await adapter.close();
}
}
if (require.main === module) {
main();
}
module.exports = {
buildForDocument,
extractConcepts,
splitParagraphs,
normalizeConcept,
stableId,
queryWithEmbedding,
createEmptyDb
};
FILE:dist/retriever.js
/**
* retriever.js
* 关键词召回 + 向量相似度召回双层检索。
* ranker:余弦相似度
* 最终结果 = 关键词召回 ∪ 向量召回 → 去重排序
*/
const { normalizeConcept, STOPWORDS } = require('./utils/nlp');
/**
* @typedef {Object} RetrievalResult
* @property {string} fragmentId
* @property {string} documentId
* @property {string} documentTitle
* @property {number} score
* @property {string} text
* @property {'keyword'|'vector'|'hybrid'} source
*/
/**
* 计算余弦相似度
* @param {number[]} a
* @param {number[]} b
*/
function cosineSimilarity(a, b) {
if (a.length !== b.length) return 0;
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i += 1) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
const denom = Math.sqrt(na) * Math.sqrt(nb);
return denom === 0 ? 0 : dot / denom;
}
/**
* 关键词召回(从 fragment 列表)
*/
function keywordSearch(fragments, query, limit = 20) {
const terms = normalizeConcept(query)
.split(' ')
.filter(Boolean)
.filter((t) => !STOPWORDS.has(t));
if (terms.length === 0) return [];
return fragments
.map((frag) => {
const textNorm = normalizeConcept(frag.text);
let score = 0;
for (const term of terms) {
if (textNorm.includes(term)) score += 3;
if ((frag.conceptNames || []).includes(term)) score += 5;
}
return { frag, score };
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.frag.index - b.frag.index)
.slice(0, limit)
.map((item) => ({
fragmentId: item.frag.id,
documentId: item.frag.documentId,
documentTitle: item.frag.documentTitle || 'unknown',
score: item.score,
text: item.frag.text,
source: 'keyword'
}));
}
/**
* 向量相似度召回
* @param {object[]} fragments - 带 documentTitle
* @param {string} query
* @param {import('./embedding-providers/EmbeddingProvider')} embeddingProvider
* @param {number} limit
*/
async function vectorSearch(fragments, query, embeddingProvider, limit = 20) {
if (!embeddingProvider || fragments.length === 0) return [];
const texts = fragments.map((f) => f.text);
const [queryVec, fragVecs] = await Promise.all([
embeddingProvider.embed([query]),
embeddingProvider.embed(texts)
]);
const qv = queryVec[0];
return fragments
.map((frag, i) => ({
frag,
sim: cosineSimilarity(qv, fragVecs[i] || [])
}))
.filter((item) => item.sim > 0)
.sort((a, b) => b.sim - a.sim)
.slice(0, limit)
.map((item) => ({
fragmentId: item.frag.id,
documentId: item.frag.documentId,
documentTitle: item.frag.documentTitle || 'unknown',
score: item.sim,
text: item.frag.text,
source: 'vector'
}));
}
/**
* 合并关键词召回 + 向量召回,去重并排序
* @param {RetrievalResult[]} keywordResults
* @param {RetrievalResult[]} vectorResults
* @param {number} limit
*/
function mergeResults(keywordResults, vectorResults, limit = 10) {
const seen = new Map();
for (const r of [...keywordResults, ...vectorResults]) {
if (!seen.has(r.fragmentId)) {
seen.set(r.fragmentId, { ...r });
} else {
// 取最高分
const existing = seen.get(r.fragmentId);
existing.score = Math.max(existing.score, r.score);
existing.source = existing.source === r.source ? existing.source : 'hybrid';
}
}
return [...seen.values()]
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
/**
* 主检索入口
* @param {object} options
* @param {object[]} options.fragments - fragment 列表,需含 documentTitle
* @param {string} options.query
* @param {import('./embedding-providers/EmbeddingProvider')} [options.embeddingProvider]
* @param {number} [options.limit]
*/
async function retrieve({ fragments, query, embeddingProvider = null, limit = 10 }) {
const kw = keywordSearch(fragments, query, limit * 2);
const vec = embeddingProvider
? await vectorSearch(fragments, query, embeddingProvider, limit * 2)
: [];
return mergeResults(kw, vec, limit);
}
module.exports = {
keywordSearch,
vectorSearch,
mergeResults,
cosineSimilarity,
retrieve
};
FILE:dist/storage-adapters/JsonStorageAdapter.js
/**
* JsonStorageAdapter
* 基于本地 JSON 文件的存储实现,保持向后兼容。
*/
const fs = require('fs');
const path = require('path');
const { StorageAdapter } = require('./StorageAdapter');
const ROOT = path.resolve(__dirname, '../..');
const DATA_DIR = path.join(ROOT, 'data');
const DB_PATH = path.join(DATA_DIR, 'workspace.json');
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function now() {
return new Date().toISOString();
}
class JsonStorageAdapter extends StorageAdapter {
constructor() {
super();
this._db = null;
}
_loadDb() {
ensureDir(DATA_DIR);
if (!fs.existsSync(DB_PATH)) {
const empty = this._emptyDb();
this._saveDb(empty);
return empty;
}
return JSON.parse(fs.readFileSync(DB_PATH, 'utf8'));
}
_saveDb(db) {
db.updatedAt = now();
ensureDir(DATA_DIR);
fs.writeFileSync(DB_PATH, JSON.stringify(db, null, 2), 'utf8');
}
_emptyDb() {
return { version: 1, createdAt: now(), updatedAt: now(), documents: [], fragments: [], concepts: [], links: [] };
}
async init() {
this._db = this._loadDb();
}
// --- Document ---
async saveDocument(doc) {
const db = this._loadDb();
const idx = db.documents.findIndex((d) => d.id === doc.id);
if (idx >= 0) db.documents[idx] = doc;
else db.documents.push(doc);
this._saveDb(db);
return doc;
}
async getDocument(id) {
const db = this._loadDb();
return db.documents.find((d) => d.id === id) || null;
}
async listDocuments() {
const db = this._loadDb();
return db.documents;
}
async deleteDocument(id) {
const db = this._loadDb();
db.documents = db.documents.filter((d) => d.id !== id);
this._saveDb(db);
}
// --- Fragment ---
async saveFragment(frag) {
const db = this._loadDb();
const idx = db.fragments.findIndex((f) => f.id === frag.id);
if (idx >= 0) db.fragments[idx] = frag;
else db.fragments.push(frag);
this._saveDb(db);
return frag;
}
async getFragment(id) {
const db = this._loadDb();
return db.fragments.find((f) => f.id === id) || null;
}
async listFragments(documentId) {
const db = this._loadDb();
return db.fragments.filter((f) => f.documentId === documentId);
}
async deleteFragmentsByDocument(documentId) {
const db = this._loadDb();
db.fragments = db.fragments.filter((f) => f.documentId !== documentId);
this._saveDb(db);
}
async saveFragments(fragments) {
const db = this._loadDb();
for (const frag of fragments) {
const idx = db.fragments.findIndex((f) => f.id === frag.id);
if (idx >= 0) db.fragments[idx] = frag;
else db.fragments.push(frag);
}
this._saveDb(db);
}
// --- Concept ---
async saveConcept(concept) {
const db = this._loadDb();
const idx = db.concepts.findIndex((c) => c.id === concept.id);
if (idx >= 0) db.concepts[idx] = concept;
else db.concepts.push(concept);
this._saveDb(db);
return concept;
}
async getConcept(id) {
const db = this._loadDb();
return db.concepts.find((c) => c.id === id) || null;
}
async listConcepts() {
const db = this._loadDb();
return db.concepts;
}
async upsertConcept(concept) {
const db = this._loadDb();
const idx = db.concepts.findIndex((c) => c.id === concept.id);
if (idx >= 0) db.concepts[idx] = concept;
else db.concepts.push(concept);
this._saveDb(db);
return concept;
}
async saveConcepts(concepts) {
const db = this._loadDb();
for (const c of concepts) {
const idx = db.concepts.findIndex((x) => x.id === c.id);
if (idx >= 0) db.concepts[idx] = c;
else db.concepts.push(c);
}
this._saveDb(db);
}
// --- Link ---
async saveLink(link) {
const db = this._loadDb();
const idx = db.links.findIndex((l) => l.id === link.id);
if (idx >= 0) db.links[idx] = link;
else db.links.push(link);
this._saveDb(db);
return link;
}
async getLinks(fromId, toId) {
const db = this._loadDb();
return db.links.filter(
(l) => (fromId ? l.fromId === fromId : true) && (toId ? l.toId === toId : true)
);
}
async deleteLinksByDocument(documentId) {
const db = this._loadDb();
db.links = db.links.filter((l) => l.documentId !== documentId);
this._saveDb(db);
}
async saveLinks(links) {
const db = this._loadDb();
for (const l of links) {
const idx = db.links.findIndex((x) => x.id === l.id);
if (idx >= 0) db.links[idx] = l;
else db.links.push(l);
}
this._saveDb(db);
}
// --- Full DB operations (for CLI compat) ---
async load() {
return this._loadDb();
}
async save(db) {
this._saveDb(db);
}
async close() {
// no-op for JSON
}
// --- Workspace ---
async clear() {
this._saveDb(this._emptyDb());
}
// --- Stats ---
getStats() {
const db = this._loadDb();
return {
documents: db.documents.length,
fragments: db.fragments.length,
concepts: db.concepts.length,
links: db.links.length,
updatedAt: db.updatedAt,
dbPath: DB_PATH
};
}
}
module.exports = { JsonStorageAdapter };
FILE:dist/storage-adapters/SqliteStorageAdapter.js
/**
* SqliteStorageAdapter
* 基于 better-sqlite3 的 SQLite 存储实现。
* 建表语句 + 所有 StorageAdapter 方法的完整实现。
*/
const { StorageAdapter } = require('./StorageAdapter');
let sqlite3 = null;
try {
sqlite3 = require('better-sqlite3');
} catch {
// better-sqlite3 not installed — adapter will throw on init()
}
const ROOT = path => require('path').resolve(__dirname, '../..');
const DATA_DIR = path.join(ROOT, 'data');
const DB_PATH = path.join(DATA_DIR, 'db.sqlite');
function ensureDir(dir) {
require('fs').mkdirSync(dir, { recursive: true });
}
function now() {
return new Date().toISOString();
}
const SCHEMA = `
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'document',
title TEXT,
sourceType TEXT,
sourceUri TEXT,
importedAt TEXT,
status TEXT DEFAULT 'active',
text TEXT,
createdAt TEXT,
updatedAt TEXT
);
CREATE TABLE IF NOT EXISTS fragments (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'fragment',
documentId TEXT,
"index" INTEGER,
text TEXT,
summary TEXT,
conceptNames TEXT,
createdAt TEXT,
FOREIGN KEY (documentId) REFERENCES documents(id)
);
CREATE TABLE IF NOT EXISTS concepts (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'concept',
name TEXT,
normalizedName TEXT UNIQUE,
salience REAL,
createdAt TEXT
);
CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY,
type TEXT,
fromId TEXT,
fromType TEXT,
toId TEXT,
toType TEXT,
documentId TEXT,
score REAL,
createdAt TEXT,
FOREIGN KEY (documentId) REFERENCES documents(id)
);
CREATE TABLE IF NOT EXISTS fragment_vectors (
fragmentId TEXT PRIMARY KEY,
vector BLOB,
updatedAt TEXT,
FOREIGN KEY (fragmentId) REFERENCES fragments(id)
);
CREATE INDEX IF NOT EXISTS idx_fragments_documentId ON fragments(documentId);
CREATE INDEX IF NOT EXISTS idx_links_fromId ON links(fromId);
CREATE INDEX IF NOT EXISTS idx_links_toId ON links(toId);
CREATE INDEX IF NOT EXISTS idx_links_documentId ON links(documentId);
CREATE INDEX IF NOT EXISTS idx_concepts_normalizedName ON concepts(normalizedName);
`;
class SqliteStorageAdapter extends StorageAdapter {
constructor({ dbPath } = {}) {
super();
this._dbPath = dbPath || DB_PATH;
this._db = null;
}
async init() {
if (!sqlite3) {
throw new Error('better-sqlite3 is not installed. Run: npm install better-sqlite3');
}
ensureDir(require('path').dirname(this._dbPath));
this._db = sqlite3(this._dbPath);
this._db.pragma('journal_mode = WAL');
this._db.exec(SCHEMA);
}
_run(sql, params = []) {
try {
return this._db.prepare(sql).run(...params);
} catch (e) {
throw new Error(`SQLite run error: e.message | sql: sql`);
}
}
_all(sql, params = []) {
try {
return this._db.prepare(sql).all(...params);
} catch (e) {
throw new Error(`SQLite all error: e.message | sql: sql`);
}
}
_get(sql, params = []) {
try {
return this._db.prepare(sql).get(...params);
} catch (e) {
throw new Error(`SQLite get error: e.message | sql: sql`);
}
}
// --- Document ---
async saveDocument(doc) {
this._run(
`INSERT OR REPLACE INTO documents (id,type,title,sourceType,sourceUri,importedAt,status,text,createdAt,updatedAt)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
[doc.id, doc.type || 'document', doc.title || '', doc.sourceType || '', doc.sourceUri || '',
doc.importedAt || now(), doc.status || 'active', doc.text || '',
doc.createdAt || now(), now()]
);
return doc;
}
async getDocument(id) {
const row = this._get('SELECT * FROM documents WHERE id = ?', [id]);
return row ? this._mapDoc(row) : null;
}
async listDocuments() {
return this._all('SELECT * FROM documents ORDER BY importedAt DESC').map(this._mapDoc);
}
async deleteDocument(id) {
this._run('DELETE FROM documents WHERE id = ?', [id]);
}
// --- Fragment ---
async saveFragment(frag) {
this._run(
`INSERT OR REPLACE INTO fragments (id,type,documentId,"index",text,summary,conceptNames,createdAt)
VALUES (?,?,?,?,?,?,?,?)`,
[frag.id, 'fragment', frag.documentId, frag.index, frag.text, frag.summary || frag.text.slice(0, 100),
JSON.stringify(frag.conceptNames || []), frag.createdAt || now()]
);
return frag;
}
async getFragment(id) {
const row = this._get('SELECT * FROM fragments WHERE id = ?', [id]);
return row ? this._mapFrag(row) : null;
}
async listFragments(documentId) {
return this._all('SELECT * FROM fragments WHERE documentId = ? ORDER BY "index" ASC', [documentId])
.map(this._mapFrag);
}
async deleteFragmentsByDocument(documentId) {
this._run('DELETE FROM fragments WHERE documentId = ?', [documentId]);
}
async saveFragments(fragments) {
const stmt = this._db.prepare(
`INSERT OR REPLACE INTO fragments (id,type,documentId,"index",text,summary,conceptNames,createdAt)
VALUES (?,?,?,?,?,?,?,?)`
);
const insertMany = this._db.transaction((items) => {
for (const frag of items) {
stmt.run(frag.id, 'fragment', frag.documentId, frag.index, frag.text,
frag.summary || frag.text.slice(0, 100), JSON.stringify(frag.conceptNames || []),
frag.createdAt || now());
}
});
insertMany(fragments);
return fragments;
}
// --- Concept ---
async saveConcept(concept) {
this._run(
`INSERT OR REPLACE INTO concepts (id,type,name,normalizedName,salience,createdAt)
VALUES (?,?,?,?,?,?)`,
[concept.id, 'concept', concept.name, concept.normalizedName, concept.salience || 0,
concept.createdAt || now()]
);
return concept;
}
async getConcept(id) {
const row = this._get('SELECT * FROM concepts WHERE id = ?', [id]);
return row ? this._mapConcept(row) : null;
}
async listConcepts() {
return this._all('SELECT * FROM concepts ORDER BY normalizedName ASC').map(this._mapConcept);
}
async upsertConcept(concept) {
return this.saveConcept(concept);
}
async saveConcepts(concepts) {
const stmt = this._db.prepare(
`INSERT OR REPLACE INTO concepts (id,type,name,normalizedName,salience,createdAt) VALUES (?,?,?,?,?,?)`
);
const insertMany = this._db.transaction((items) => {
for (const c of items) {
stmt.run(c.id, 'concept', c.name, c.normalizedName, c.salience || 0, c.createdAt || now());
}
});
insertMany(concepts);
return concepts;
}
// --- Link ---
async saveLink(link) {
this._run(
`INSERT OR REPLACE INTO links (id,type,fromId,fromType,toId,toType,documentId,score,createdAt)
VALUES (?,?,?,?,?,?,?,?,?)`,
[link.id, link.type, link.fromId, link.fromType, link.toId, link.toType,
link.documentId, link.score || 0, link.createdAt || now()]
);
return link;
}
async getLinks(fromId, toId) {
let sql = 'SELECT * FROM links WHERE 1=1';
const params = [];
if (fromId) { sql += ' AND fromId = ?'; params.push(fromId); }
if (toId) { sql += ' AND toId = ?'; params.push(toId); }
return this._all(sql, params);
}
async deleteLinksByDocument(documentId) {
this._run('DELETE FROM links WHERE documentId = ?', [documentId]);
}
async saveLinks(links) {
const stmt = this._db.prepare(
`INSERT OR REPLACE INTO links (id,type,fromId,fromType,toId,toType,documentId,score,createdAt)
VALUES (?,?,?,?,?,?,?,?,?)`
);
const insertMany = this._db.transaction((items) => {
for (const l of items) {
stmt.run(l.id, l.type, l.fromId, l.fromType, l.toId, l.toType,
l.documentId, l.score || 0, l.createdAt || now());
}
});
insertMany(links);
return links;
}
// --- Vector ---
async saveFragmentVector(fragmentId, vector) {
const buf = Buffer.from(JSON.stringify(vector));
this._run(
`INSERT OR REPLACE INTO fragment_vectors (fragmentId,vector,updatedAt) VALUES (?,?,?)`,
[fragmentId, buf, now()]
);
}
async getFragmentVector(fragmentId) {
const row = this._get('SELECT vector FROM fragment_vectors WHERE fragmentId = ?', [fragmentId]);
if (!row) return null;
return JSON.parse(row.vector);
}
async listFragmentVectors() {
return this._all('SELECT fragmentId, vector FROM fragment_vectors').map((r) => ({
fragmentId: r.fragmentId,
vector: JSON.parse(r.vector)
}));
}
// --- Full DB operations (for CLI compat) ---
async load() {
const docs = this._all('SELECT * FROM documents ORDER BY importedAt DESC').map(this._mapDoc);
const frags = this._all('SELECT * FROM fragments ORDER BY "index" ASC').map(this._mapFrag);
const concepts = this._all('SELECT * FROM concepts ORDER BY normalizedName ASC').map(this._mapConcept);
const links = this._all('SELECT * FROM links');
const updatedAt = this._get('SELECT MAX(updatedAt) as t FROM documents UNION ALL SELECT MAX(updatedAt) as t FROM fragments')?.t || new Date().toISOString();
return { version: 1, documents: docs, fragments: frags, concepts, links, updatedAt };
}
async save(db) {
await this.clear();
if (db.documents) for (const d of db.documents) await this.saveDocument(d);
if (db.fragments) for (const f of db.fragments) await this.saveFragment(f);
if (db.concepts) for (const c of db.concepts) await this.saveConcept(c);
if (db.links) for (const l of db.links) await this.saveLink(l);
}
async close() {
if (this._db) { this._db.close(); this._db = null; }
}
// --- Workspace ---
async clear() {
this._run('DELETE FROM fragment_vectors');
this._run('DELETE FROM links');
this._run('DELETE FROM fragments');
this._run('DELETE FROM concepts');
this._run('DELETE FROM documents');
}
getStats() {
return {
documents: this._get('SELECT COUNT(*) as n FROM documents')?.n || 0,
fragments: this._get('SELECT COUNT(*) as n FROM fragments')?.n || 0,
concepts: this._get('SELECT COUNT(*) as n FROM concepts')?.n || 0,
links: this._get('SELECT COUNT(*) as n FROM links')?.n || 0,
dbPath: this._dbPath
};
}
// --- Mappers ---
_mapDoc(row) {
return { ...row };
}
_mapFrag(row) {
return { ...row, conceptNames: JSON.parse(row.conceptNames || '[]') };
}
_mapConcept(row) {
return { ...row };
}
}
module.exports = { SqliteStorageAdapter };
FILE:dist/storage-adapters/StorageAdapter.js
/**
* StorageAdapter Interface
* 所有 storage adapter 必须实现此接口契约。
*/
class StorageAdapter {
/**
* @returns {Promise<void>}
*/
async init() {
throw new Error('Not implemented');
}
// --- Document ---
async saveDocument(doc) {
throw new Error('Not implemented');
}
async getDocument(id) {
throw new Error('Not implemented');
}
async listDocuments() {
throw new Error('Not implemented');
}
async deleteDocument(id) {
throw new Error('Not implemented');
}
// --- Fragment ---
async saveFragment(frag) {
throw new Error('Not implemented');
}
async getFragment(id) {
throw new Error('Not implemented');
}
async listFragments(documentId) {
throw new Error('Not implemented');
}
async deleteFragmentsByDocument(documentId) {
throw new Error('Not implemented');
}
// --- Concept ---
async saveConcept(concept) {
throw new Error('Not implemented');
}
async getConcept(id) {
throw new Error('Not implemented');
}
async listConcepts() {
throw new Error('Not implemented');
}
async upsertConcept(concept) {
throw new Error('Not implemented');
}
// --- Link ---
async saveLink(link) {
throw new Error('Not implemented');
}
async getLinks(fromId, toId) {
throw new Error('Not implemented');
}
async deleteLinksByDocument(documentId) {
throw new Error('Not implemented');
}
// --- Bulk ---
async saveFragments(fragments) {
throw new Error('Not implemented');
}
async saveConcepts(concepts) {
throw new Error('Not implemented');
}
async saveLinks(links) {
throw new Error('Not implemented');
}
// --- Workspace ---
async clear() {
throw new Error('Not implemented');
}
}
module.exports = { StorageAdapter };
FILE:dist/storage-adapters/index.js
/**
* storage-adapters/index.js
* Factory: 根据配置返回对应的 storage adapter 实例。
*/
const { JsonStorageAdapter } = require('./JsonStorageAdapter');
const { SqliteStorageAdapter } = require('./SqliteStorageAdapter');
function createStorageAdapter(type = 'json', options = {}) {
switch (type) {
case 'json':
return new JsonStorageAdapter(options);
case 'sqlite':
return new SqliteStorageAdapter(options);
default:
throw new Error(`Unknown storage adapter type: type. Use 'json' or 'sqlite'.`);
}
}
module.exports = { createStorageAdapter, JsonStorageAdapter, SqliteStorageAdapter };
FILE:dist/utils/nlp.js
/**
* nlp.js - 文本规范化工具
*/
const STOPWORDS = new Set([
'the','and','for','that','this','with','from','into','your','have','will','not','are','was','were','can','could','should','about','what','when','where','which','while','than','then','them','they','their','there','here','also','more','most','some','such','using','used','use','make','made','over','under','very','just','only','each','been','being','does','did','done','how','why','our','you','its','his','her','she','him','who','has','had','but','too','via','per','one','two','three',
'我们','你们','他们','这个','那个','一个','一种','可以','需要','如果','因为','所以','以及','进行','通过','没有','不是','就是','如何','什么','为什么','时候','今天','已经','还有','对于','一些','这种','那些','并且','或者','主要','用户','系统','功能','能力','作为','实现','相关','用于'
]);
function normalizeConcept(text) {
if (!text) return '';
return text
.toLowerCase()
.replace(/[\u2018\u2019'"""''()()【】\[\],.:;!?/\\]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
module.exports = { normalizeConcept, STOPWORDS };
FILE:examples/sample-note.md
# LinkMind sample note
LinkMind is a connected knowledge system. It ingests notes and documents, splits them into fragments, extracts concepts, and builds explainable links.
The MVP focuses on a local JSON workspace. This keeps the implementation simple while preserving a future path toward vector retrieval, graph storage, and richer evidence assembly.
Knowledge Connector is the capability layer. LinkMind is the user-facing product layer built on top of it.
Good retrieval should return answer, evidence, and related concepts instead of a single opaque paragraph.
FILE:package.json
{
"name": "linkmind",
"version": "0.2.0",
"description": "LinkMind MVP skeleton - local knowledge connector with ingest/build/query CLI",
"main": "dist/index.js",
"bin": {
"linkmind": "dist/index.js"
},
"scripts": {
"build": "cp -r src/storage-adapters dist/ && cp -r src/embedding-providers dist/ && cp -r src/utils dist/ && cp src/index.js dist/ && cp src/retriever.js dist/ && chmod +x dist/index.js",
"test": "node tests/smoke-test.js"
},
"keywords": ["knowledge", "graph", "rag", "linkmind"],
"author": "Golden Bean",
"license": "MIT",
"optionalDependencies": {
"sqlite3": "^5.1.7"
}
}
FILE:skill.json
{
"name": "linkmind",
"version": "1.0.0",
"description": "知识连接引擎 - 本地化知识中枢 CLI 工具,支持 storage adapter 抽象层和 OpenAI-compatible embedding provider",
"keywords": ["linkmind", "knowledge-management", "embedding", "local-knowledge", "cli", "vector-search"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/linkmind",
"language": ["en", "zh"],
"tags": ["knowledge-management", "embedding", "local-knowledge", "cli"],
"createdAt": "2026-04-04",
"updatedAt": "2026-04-05"
}
FILE:src/adapters/base.js
/**
* StorageAdapter 接口契约
* 所有 adapter 必须实现以下方法:
* load() -> Promise<WorkspaceDb>
* save(db) -> Promise<void>
* clear() -> Promise<void>
* query(fn) -> Promise<any> (事务查询)
* close() -> Promise<void>
*/
class StorageAdapter {
async load() {
throw new Error('Not implemented: load()');
}
async save(db) {
throw new Error('Not implemented: save(db)');
}
async clear() {
throw new Error('Not implemented: clear()');
}
async query(fn) {
throw new Error('Not implemented: query(fn)');
}
async close() {
throw new Error('Not implemented: close()');
}
}
module.exports = { StorageAdapter };
FILE:src/adapters/json.js
/**
* JsonStorageAdapter
* 保持现有 JSON 文件行为,完全兼容旧数据格式。
*/
const fs = require('fs');
const path = require('path');
const { StorageAdapter } = require('./base');
const ROOT = __dirname.includes(`path.sepdist`)
? path.resolve(__dirname, '../..')
: path.resolve(__dirname, '../..');
const DATA_DIR = path.join(ROOT, 'data');
const DB_PATH = path.join(DATA_DIR, 'workspace.json');
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
class JsonStorageAdapter extends StorageAdapter {
constructor(opts = {}) {
super();
this.dbPath = opts.dbPath || DB_PATH;
this.dataDir = opts.dataDir || DATA_DIR;
}
_createEmpty() {
return {
version: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
documents: [],
fragments: [],
concepts: [],
links: []
};
}
async load() {
ensureDir(this.dataDir);
if (!fs.existsSync(this.dbPath)) {
const empty = this._createEmpty();
await this.save(empty);
return empty;
}
return JSON.parse(fs.readFileSync(this.dbPath, 'utf8'));
}
async save(db) {
db.updatedAt = new Date().toISOString();
ensureDir(this.dataDir);
fs.writeFileSync(this.dbPath, JSON.stringify(db, null, 2), 'utf8');
}
async clear() {
await this.save(this._createEmpty());
}
async query(fn) {
// JSON adapter 不需要事务,直接执行
const db = await this.load();
return fn(db);
}
async close() {
// 无需关闭
}
dbPath() {
return this.dbPath;
}
}
module.exports = { JsonStorageAdapter };
FILE:src/adapters/sqlite.js
/**
* SqliteStorageAdapter
* SQLite 存储适配器骨架,表结构与 JSON 格式等效。
* 事务支持、批量操作能力。
*/
const path = require('path');
const { StorageAdapter } = require('./base');
// sqlite3 为可选依赖,运行时检测
let sqlite3 = null;
try {
sqlite3 = require('sqlite3');
} catch {
// sqlite3 不可用,稍后通过 init() 检测并给出明确错误
}
const ROOT = __dirname.includes(`path.sepdist`)
? path.resolve(__dirname, '../..')
: path.resolve(__dirname, '../..');
const DATA_DIR = path.resolve(ROOT, 'data');
const SCHEMA = `
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'document',
title TEXT,
source_type TEXT,
source_uri TEXT,
imported_at TEXT,
status TEXT DEFAULT 'active',
text TEXT,
metadata TEXT
);
CREATE TABLE IF NOT EXISTS fragments (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'fragment',
document_id TEXT,
frag_index INTEGER,
text TEXT,
summary TEXT,
concept_names TEXT,
metadata TEXT,
FOREIGN KEY (document_id) REFERENCES documents(id)
);
CREATE TABLE IF NOT EXISTS concepts (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'concept',
name TEXT,
normalized_name TEXT UNIQUE,
salience REAL,
metadata TEXT
);
CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY,
type TEXT,
from_id TEXT,
from_type TEXT,
to_id TEXT,
to_type TEXT,
document_id TEXT,
score REAL,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_fragments_document ON fragments(document_id);
CREATE INDEX IF NOT EXISTS idx_links_from ON links(from_id);
CREATE INDEX IF NOT EXISTS idx_links_to ON links(to_id);
CREATE INDEX IF NOT EXISTS idx_concepts_normalized ON concepts(normalized_name);
`;
class SqliteStorageAdapter extends StorageAdapter {
constructor(opts = {}) {
super();
if (!sqlite3) {
throw new Error('sqlite3 not installed. Run: npm install sqlite3');
}
this.dbPath = opts.dbPath || path.join(DATA_DIR, 'workspace.db');
this._db = null;
}
async init() {
if (this._db) return;
return new Promise((resolve, reject) => {
this._db = new sqlite3.Database(this.dbPath, (err) => {
if (err) return reject(err);
this._db.exec(SCHEMA, (execErr) => {
if (execErr) return reject(execErr);
resolve();
});
});
});
}
async load() {
await this.init();
return new Promise((resolve, reject) => {
const docStmt = this._db.prepare('SELECT * FROM documents');
const fragStmt = this._db.prepare('SELECT * FROM fragments');
const conceptStmt = this._db.prepare('SELECT * FROM concepts');
const linkStmt = this._db.prepare('SELECT * FROM links');
const rows = { documents: [], fragments: [], concepts: [], links: [] };
const finish = () => {
// 反序列化 JSON 字段
rows.fragments = rows.fragments.map((f) => ({
...f,
id: f.id,
type: 'fragment',
documentId: f.document_id,
index: f.frag_index,
conceptNames: f.concept_names ? JSON.parse(f.concept_names) : []
}));
rows.concepts = rows.concepts.map((c) => ({
...c,
type: 'concept',
normalizedName: c.normalized_name
}));
rows.links = rows.links.map((l) => ({
...l,
fromId: l.from_id,
fromType: l.from_type,
toId: l.to_id,
toType: l.to_type,
documentId: l.document_id
}));
resolve(rows);
};
let pending = 4;
const done = () => { if (--pending === 0) finish(); };
docStmt.all((err, docs) => {
if (err) { docStmt.close(); return reject(err); }
rows.documents = docs.map((d) => ({ ...d, type: 'document' }));
docStmt.close();
done();
});
fragStmt.all((err, frags) => {
if (err) { fragStmt.close(); return reject(err); }
rows.fragments = frags;
fragStmt.close();
done();
});
conceptStmt.all((err, concepts) => {
if (err) { conceptStmt.close(); return reject(err); }
rows.concepts = concepts;
conceptStmt.close();
done();
});
linkStmt.all((err, links) => {
if (err) { linkStmt.close(); return reject(err); }
rows.links = links;
linkStmt.close();
done();
});
});
}
async save(rows) {
await this.init();
return new Promise((resolve, reject) => {
this._db.serialize(() => {
const insertDoc = this._db.prepare(
'INSERT OR REPLACE INTO documents (id,type,title,source_type,source_uri,imported_at,status,text,metadata) VALUES (?,?,?,?,?,?,?,?,?)'
);
const insertFrag = this._db.prepare(
'INSERT OR REPLACE INTO fragments (id,type,document_id,frag_index,text,summary,concept_names,metadata) VALUES (?,?,?,?,?,?,?,?)'
);
const insertConcept = this._db.prepare(
'INSERT OR REPLACE INTO concepts (id,type,name,normalized_name,salience,metadata) VALUES (?,?,?,?,?,?)'
);
const insertLink = this._db.prepare(
'INSERT OR REPLACE INTO links (id,type,from_id,from_type,to_id,to_type,document_id,score,metadata) VALUES (?,?,?,?,?,?,?,?,?)'
);
try {
for (const d of rows.documents) {
insertDoc.run(d.id, d.type, d.title, d.sourceType, d.sourceUri, d.importedAt, d.status, d.text, d.metadata ? JSON.stringify(d.metadata) : null);
}
for (const f of rows.fragments) {
insertFrag.run(f.id, f.type, f.documentId, f.index, f.text, f.summary, JSON.stringify(f.conceptNames || []), f.metadata ? JSON.stringify(f.metadata) : null);
}
for (const c of rows.concepts) {
insertConcept.run(c.id, c.type, c.name, c.normalizedName, c.salience, c.metadata ? JSON.stringify(c.metadata) : null);
}
for (const l of rows.links) {
insertLink.run(l.id, l.type, l.fromId, l.fromType, l.toId, l.toType, l.documentId, l.score, l.metadata ? JSON.stringify(l.metadata) : null);
}
resolve();
} catch (err) {
reject(err);
} finally {
insertDoc.close();
insertFrag.close();
insertConcept.close();
insertLink.close();
}
});
});
}
async clear() {
await this.init();
return new Promise((resolve, reject) => {
this._db.run('DELETE FROM links; DELETE FROM fragments; DELETE FROM concepts; DELETE FROM documents;', function (err) {
if (err) return reject(err);
resolve();
});
});
}
async query(fn) {
// 供未来事务查询使用,当前与 load 行为相同
const db = await this.load();
return fn(db);
}
async close() {
if (this._db) {
return new Promise((resolve) => {
this._db.close(() => resolve());
this._db = null;
});
}
}
}
module.exports = { SqliteStorageAdapter };
FILE:src/embedding-providers/EmbeddingProvider.js
/**
* EmbeddingProvider Interface
* 所有 embedding provider 必须实现此接口契约。
*/
class EmbeddingProvider {
/**
* 将文本列表转为向量
* @param {string[]} texts
* @returns {Promise<number[][]>}
*/
async embed(texts) {
throw new Error('Not implemented');
}
/**
* 返回 provider 名称
*/
get name() {
return 'unknown';
}
/**
* 返回向量维度
*/
get dimension() {
throw new Error('Not implemented');
}
}
module.exports = { EmbeddingProvider };
FILE:src/embedding-providers/MockProvider.js
/**
* MockProvider
* 返回随机向量,用于测试和离线开发。
*/
const { EmbeddingProvider } = require('./EmbeddingProvider');
const DEFAULT_DIM = 1536;
class MockProvider extends EmbeddingProvider {
constructor({ dimension = DEFAULT_DIM, seed = 42 } = {}) {
super();
this._dim = dimension;
this._seed = seed;
this._cache = new Map();
}
get name() {
return 'mock';
}
get dimension() {
return this._dim;
}
_pseudoRandom(text) {
let hash = 0;
for (let i = 0; i < text.length; i += 1) {
hash = ((hash << 5) - hash + text.charCodeAt(i)) | 0;
}
return Math.abs(hash) / 0x7fffffff;
}
async embed(texts) {
return texts.map((text) => {
if (this._cache.has(text)) return this._cache.get(text);
const vec = [];
for (let i = 0; i < this._dim; i += 1) {
// deterministic random based on text + index
const base = this._pseudoRandom(text + i);
vec.push(base);
}
// L2 normalize
const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0));
const normalized = norm > 0 ? vec.map((v) => v / norm) : vec;
this._cache.set(text, normalized);
return normalized;
});
}
}
module.exports = { MockProvider };
FILE:src/embedding-providers/OpenAICompatibleProvider.js
/**
* OpenAICompatibleProvider
* 调用 OpenAI-compatible API endpoint(如 vLLM、Ollama、Azure OpenAI 等)。
*/
const { EmbeddingProvider } = require('./EmbeddingProvider');
const DEFAULT_DIM = 1536;
class OpenAICompatibleProvider extends EmbeddingProvider {
constructor({ baseURL = 'https://api.openai.com/v1', apiKey = '', model = 'text-embedding-3-small', dimension = DEFAULT_DIM, batchSize = 100 } = {}) {
super();
this._baseURL = baseURL.replace(/\/$/, '');
this._apiKey = apiKey;
this._model = model;
this._dim = dimension;
this._batchSize = batchSize;
}
get name() {
return `openai-compatible:this._model`;
}
get dimension() {
return this._dim;
}
async embed(texts) {
if (!texts || texts.length === 0) return [];
const results = [];
for (let i = 0; i < texts.length; i += this._batchSize) {
const batch = texts.slice(i, i + this._batchSize);
const vectors = await this._fetchBatch(batch);
results.push(...vectors);
}
return results;
}
async _fetchBatch(batch) {
const url = `this._baseURL/embeddings`;
const body = {
model: this._model,
input: batch
};
const headers = {
'Content-Type': 'application/json'
};
if (this._apiKey) {
headers['Authorization'] = `Bearer this._apiKey`;
}
const resp = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body)
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
throw new Error(`Embedding API error resp.status: text`);
}
const json = await resp.json();
if (!json.data || !Array.isArray(json.data)) {
throw new Error(`Unexpected embedding response format`);
}
// sort by index to maintain order
const sorted = json.data.slice().sort((a, b) => a.index - b.index);
return sorted.map((item) => {
if (!item.embedding || !Array.isArray(item.embedding)) {
throw new Error(`Invalid embedding vector in response`);
}
return item.embedding;
});
}
}
module.exports = { OpenAICompatibleProvider };
FILE:src/embedding-providers/index.js
/**
* embedding-providers/index.js
* Factory: 根据配置返回对应的 embedding provider 实例。
*/
const { MockProvider } = require('./MockProvider');
const { OpenAICompatibleProvider } = require('./OpenAICompatibleProvider');
function createEmbeddingProvider(type = 'mock', options = {}) {
switch (type) {
case 'mock':
return new MockProvider(options);
case 'openai':
return new OpenAICompatibleProvider(options);
default:
throw new Error(`Unknown embedding provider type: type. Use 'mock' or 'openai'.`);
}
}
module.exports = { createEmbeddingProvider, MockProvider, OpenAICompatibleProvider };
FILE:src/index.js
#!/usr/bin/env node
/**
* LinkMind CLI - 知识连接引擎
* 支持 adapter 切换(json/sqlite)和 embedding 切换(keyword/openai)
*/
const fs = require('fs');
const path = require('path');
const THIS_DIR = __dirname.includes(`path.sepdist`) ? __dirname : __dirname;
const IS_DIST = THIS_DIR.includes(`path.sepdist`);
const ROOT = IS_DIST ? path.resolve(THIS_DIR, '..') : path.resolve(THIS_DIR, '..');
const DATA_DIR = path.join(ROOT, 'data');
function createEmptyDb() {
return {
version: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
documents: [],
fragments: [],
concepts: [],
links: []
};
}
function stableId(prefix, input) {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
}
return `prefix_Math.abs(hash).toString(36)`;
}
function normalizeConcept(text) {
return text
.toLowerCase()
.replace(/[\u2018\u2019'"""''()()【】\[\],.:;!?/\\]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
const STOPWORDS = new Set([
'the','and','for','that','this','with','from','into','your','have','will','not','are','was','were',
'can','could','should','about','what','when','where','which','while','than','then','them','they',
'their','there','here','also','more','most','some','such','using','used','use','make','made',
'over','under','very','just','only','each','been','being','does','did','done','how','why',
'our','you','its','his','her','she','him','who','has','had','but','too','via','per',
'one','two','three',
'我们','你们','他们','这个','那个','一个','一种','可以','需要','如果','因为','所以','以及',
'进行','通过','没有','不是','就是','如何','什么','为什么','时候','今天','已经','还有',
'对于','一些','这种','那些','并且','或者','主要','用户','系统','功能','能力','作为'
]);
function extractConcepts(text) {
const zhMatches = text.match(/[\u4e00-\u9fa5]{2,8}/g) || [];
const enMatches = text.match(/[A-Za-z][A-Za-z0-9_-]{2,}/g) || [];
const tokens = [...zhMatches, ...enMatches]
.map((item) => item.trim())
.map((item) => ({ raw: item, normalized: normalizeConcept(item) }))
.filter((item) => item.normalized && !STOPWORDS.has(item.normalized));
const counts = new Map();
for (const token of tokens) {
counts.set(token.normalized, (counts.get(token.normalized) || 0) + 1);
}
return [...counts.entries()]
.map(([normalizedName, count]) => ({
normalizedName,
name: tokens.find((item) => item.normalized === normalizedName)?.raw || normalizedName,
count
}))
.sort((a, b) => b.count - a.count || a.normalizedName.localeCompare(b.normalizedName))
.slice(0, 12);
}
function splitParagraphs(text) {
return text
.split(/\n\s*\n+/)
.map((part) => part.trim())
.filter(Boolean);
}
function upsertDocument(db, doc) {
const existingIndex = db.documents.findIndex((item) => item.id === doc.id);
if (existingIndex >= 0) db.documents[existingIndex] = doc;
else db.documents.push(doc);
}
function buildForDocument(db, document) {
const fragmentIds = db.fragments.filter((f) => f.documentId === document.id).map((f) => f.id);
db.fragments = db.fragments.filter((f) => f.documentId !== document.id);
db.links = db.links.filter((l) => !fragmentIds.includes(l.fromId) && !fragmentIds.includes(l.toId) && l.documentId !== document.id);
const usedConceptIds = new Set();
for (const link of db.links) {
if (link.toType === 'concept') usedConceptIds.add(link.toId);
if (link.fromType === 'concept') usedConceptIds.add(link.fromId);
}
db.concepts = db.concepts.filter((c) => usedConceptIds.has(c.id));
const fragments = splitParagraphs(document.text).map((text, index) => ({
id: stableId('frag', `document.id:index:text.slice(0, 80)`),
type: 'fragment',
documentId: document.id,
index,
text,
summary: text.slice(0, 100),
conceptNames: []
}));
const conceptMap = new Map(db.concepts.map((c) => [c.normalizedName, c]));
const links = [];
for (const fragment of fragments) {
const fragmentConcepts = extractConcepts(fragment.text).slice(0, 6);
fragment.conceptNames = fragmentConcepts.map((item) => item.normalizedName);
for (const concept of fragmentConcepts) {
if (!conceptMap.has(concept.normalizedName)) {
conceptMap.set(concept.normalizedName, {
id: stableId('concept', concept.normalizedName),
type: 'concept',
name: concept.name,
normalizedName: concept.normalizedName,
salience: Math.min(1, concept.count / 3)
});
}
const conceptNode = conceptMap.get(concept.normalizedName);
links.push({
id: stableId('link', `fragment.id->conceptNode.id`),
type: 'mentions',
fromId: fragment.id,
fromType: 'fragment',
toId: conceptNode.id,
toType: 'concept',
documentId: document.id,
score: concept.count
});
}
}
for (let i = 0; i < fragments.length - 1; i += 1) {
links.push({
id: stableId('link', `fragments[i].id->fragments[i + 1].id`),
type: 'adjacent',
fromId: fragments[i].id,
fromType: 'fragment',
toId: fragments[i + 1].id,
toType: 'fragment',
documentId: document.id,
score: 0.4
});
}
db.fragments.push(...fragments);
db.links.push(...links);
db.concepts = [...conceptMap.values()].sort((a, b) => a.normalizedName.localeCompare(b.normalizedName));
return { fragmentsCreated: fragments.length, linksCreated: links.length, conceptsTotal: db.concepts.length };
}
async function queryWithEmbedding(adapter, embeddingProvider, q, options = {}) {
const db = await adapter.load();
const query = normalizeConcept(q || '');
if (!query) throw new Error('Query is required');
const terms = query.split(' ').filter(Boolean);
let scored = db.fragments.map((fragment) => {
const textNorm = normalizeConcept(fragment.text);
let score = 0;
for (const term of terms) {
if (textNorm.includes(term)) score += 3;
if (fragment.conceptNames.includes(term)) score += 5;
}
return { fragment, score };
}).filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.fragment.index - b.fragment.index)
.slice(0, Number(options.limit || 3));
// Vector re-rank if real embedding provider (not KeywordEmbeddingProvider)
if (embeddingProvider && embeddingProvider.constructor.name !== 'KeywordEmbeddingProvider') {
try {
const queryVec = await embeddingProvider.embed(q);
const fragTexts = scored.length > 0 ? scored.map((s) => s.fragment.text) : db.fragments.slice(0, 20).map((f) => f.text);
const fragVecs = await embeddingProvider.embedBatch(fragTexts);
if (fragVecs.length > 0) {
const scoredWithVec = scored.length > 0
? scored.map((s, i) => ({ ...s, vecScore: embeddingProvider.similarity(queryVec, fragVecs[i]) }))
: db.fragments.slice(0, 20).map((f, i) => ({ fragment: f, score: 0, vecScore: embeddingProvider.similarity(queryVec, fragVecs[i]) }));
scoredWithVec.forEach((item) => {
item.blendedScore = item.score * 0.6 + item.vecScore * 10 * 0.4;
});
scored = scoredWithVec.sort((a, b) => b.blendedScore - a.blendedScore).slice(0, Number(options.limit || 3));
}
} catch {
// No API key or vector search failed, keep keyword results
}
}
const evidence = scored.map((item) => {
const doc = db.documents.find((d) => d.id === item.fragment.documentId);
return {
fragmentId: item.fragment.id,
documentId: item.fragment.documentId,
documentTitle: doc?.title || 'unknown',
score: item.score || item.vecScore,
text: item.fragment.text
};
});
const relatedConcepts = [...new Set(scored.flatMap((item) => item.fragment.conceptNames))]
.map((name) => db.concepts.find((c) => c.normalizedName === name))
.filter(Boolean)
.slice(0, 8)
.map((concept) => ({ id: concept.id, name: concept.name, normalizedName: concept.normalizedName }));
const answer = evidence.length
? `Found evidence.length relevant fragments for "q". Top evidence comes from [...new Set(evidence.map((item) => item.documentTitle))].join(', ').`
: `No strong match found for "q". Try a broader concept or ingest more documents.`;
return {
query: q,
answer,
evidence,
relatedConcepts,
stats: { documents: db.documents.length, fragments: db.fragments.length, concepts: db.concepts.length, links: db.links.length }
};
}
function parseArgs(argv) {
const result = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (!token.startsWith('--')) {
result._.push(token);
continue;
}
const [key, inline] = token.slice(2).split('=');
if (inline !== undefined) {
result[key] = inline;
continue;
}
const next = argv[i + 1];
if (!next || next.startsWith('--')) {
result[key] = true;
} else {
result[key] = next;
i += 1;
}
}
return result;
}
function printHelp() {
console.log(`LinkMind MVP CLI v0.2.0
Commands:
ingest --file <path> [--title <title>] [--sourceType <type>] [--storage json|sqlite]
query --q <text> [--limit <n>] [--embedding keyword|openai]
status [--storage json|sqlite]
reset [--storage json|sqlite]
help
Options:
--storage <adapter> Storage: json (default) or sqlite
--embedding <provider> Embedding: keyword (default) or openai
--db-path <path> Custom db path
Examples:
node dist/index.js ingest --file examples/sample-note.md --title "Sample"
node dist/index.js query --q "knowledge connector"
node dist/index.js status --storage sqlite
node dist/index.js reset --storage sqlite`);
}
async function main() {
const [, , command, ...rest] = process.argv;
const args = parseArgs(rest);
const storageType = args.storage || 'json';
const embeddingType = args.embedding || 'keyword';
const dbPathArg = args['db-path'] || undefined;
let adapter = null;
try {
if (!command || command === 'help' || args.help) {
printHelp();
return;
}
try {
const adaptersDir = IS_DIST ? 'storage-adapters' : 'src/storage-adapters';
if (storageType === 'sqlite') {
const { SqliteStorageAdapter } = require(path.join(THIS_DIR, adaptersDir, 'SqliteStorageAdapter.js'));
adapter = new SqliteStorageAdapter(dbPathArg ? { dbPath: dbPathArg } : {});
} else {
const { JsonStorageAdapter } = require(path.join(THIS_DIR, adaptersDir, 'JsonStorageAdapter.js'));
adapter = new JsonStorageAdapter(dbPathArg ? { dbPath: dbPathArg } : {});
}
} catch (e) {
if (e.message.includes('sqlite3 not installed')) {
console.error('[LinkMind] sqlite3 not installed. Run: npm install sqlite3');
} else {
console.error(`[LinkMind] Adapter load error: e.message`);
}
process.exitCode = 1;
return;
}
let embeddingProvider = null;
try {
const providersDir = IS_DIST ? 'embedding-providers' : 'src/embedding-providers';
const { MockProvider, OpenAICompatibleProvider } = require(path.join(THIS_DIR, providersDir, 'index.js'));
embeddingProvider = embeddingType === 'openai'
? new OpenAICompatibleProvider({ baseURL: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', apiKey: process.env.OPENAI_API_KEY || '', model: process.env.OPENAI_MODEL || 'text-embedding-3-small' })
: new MockProvider();
} catch {
embeddingProvider = null;
}
const now = () => new Date().toISOString();
if (command === 'ingest') {
if (!args.file) throw new Error('--file is required for ingest');
const full = path.resolve(process.cwd(), args.file);
if (!fs.existsSync(full)) throw new Error(`File not found: full`);
const text = fs.readFileSync(full, 'utf8');
const db = await adapter.load();
const doc = {
id: stableId('doc', `full:args.title || path.basename(full)`),
type: 'document',
title: args.title || path.basename(full),
sourceType: args.sourceType || 'file',
sourceUri: full,
importedAt: now(),
status: 'active',
text
};
upsertDocument(db, doc);
const stats = buildForDocument(db, doc);
await adapter.save(db);
console.log(JSON.stringify({ documentId: doc.id, title: doc.title, ...stats }, null, 2));
return;
}
if (command === 'query') {
if (!args.q && !args.query) throw new Error('--q is required for query');
const result = await queryWithEmbedding(adapter, embeddingProvider, args.q || args.query, { limit: args.limit });
console.log(JSON.stringify(result, null, 2));
return;
}
if (command === 'status') {
const db = await adapter.load();
console.log(JSON.stringify({
documents: db.documents.length,
fragments: db.fragments.length,
concepts: db.concepts.length,
links: db.links.length,
updatedAt: db.updatedAt,
adapter: storageType,
embedding: embeddingType
}, null, 2));
return;
}
if (command === 'reset') {
await adapter.clear();
console.log(JSON.stringify({ ok: true, adapter: storageType }, null, 2));
return;
}
throw new Error(`Unknown command: command`);
} catch (error) {
console.error(`[LinkMind] error.message`);
process.exitCode = 1;
} finally {
if (adapter) await adapter.close();
}
}
if (require.main === module) {
main();
}
module.exports = {
buildForDocument,
extractConcepts,
splitParagraphs,
normalizeConcept,
stableId,
queryWithEmbedding,
createEmptyDb
};
FILE:src/providers/base.js
/**
* EmbeddingProvider 接口契约
* 所有 provider 必须实现以下方法:
* embed(text) -> Promise<number[]> 单文本向量
* embedBatch(texts) -> Promise<number[][]> 批量向量
* similarity(a, b) -> number 余弦相似度
* dimensions() -> number 向量维度
*/
class EmbeddingProvider {
async embed(text) {
throw new Error('Not implemented: embed(text)');
}
async embedBatch(texts) {
throw new Error('Not implemented: embedBatch(texts)');
}
similarity(a, b) {
if (a.length !== b.length) throw new Error('Vector dimensions mismatch');
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB) + 1e-10);
}
dimensions() {
throw new Error('Not implemented: dimensions()');
}
}
module.exports = { EmbeddingProvider };
FILE:src/providers/openai.js
/**
* KeywordEmbeddingProvider
* MVP 版本:基于词项匹配的伪嵌入,用于无外部 API 依赖场景。
* 后续可替换为真正的 OpenAI/text-embedding-3* provider。
*/
const { EmbeddingProvider } = require('./base');
class KeywordEmbeddingProvider extends EmbeddingProvider {
constructor(opts = {}) {
super();
this._dims = opts.dimensions || 384; // 与 text-embedding-3-small 常用维度兼容
}
dimensions() {
return this._dims;
}
/**
* 简单词袋伪嵌入:基于 term frequency 构建稀疏向量
* 兼容性接口,实际向量召回走 retriever 的关键词逻辑
*/
async embed(text) {
const terms = (text || '')
.toLowerCase()
.replace(/[^\w\s\u4e00-\u9fa5]/g, ' ')
.split(/\s+/)
.filter(Boolean);
// 生成确定性伪向量(基于 term hash)
const vec = new Array(this._dims).fill(0);
for (const term of terms) {
const hash = this._hash(term);
for (let i = 0; i < terms.length; i++) {
const idx = (hash + i * 31) % this._dims;
vec[idx] += 1;
}
}
// L2 normalize
const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0));
return norm > 0 ? vec.map((v) => v / norm) : vec;
}
async embedBatch(texts) {
return Promise.all(texts.map((t) => this.embed(t)));
}
_hash(s) {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
}
return Math.abs(h);
}
}
/**
* OpenAiEmbeddingProvider
* OpenAI-compatible API 调用桩,后续填入真实 API key 和 endpoint 即可启用。
*/
class OpenAiEmbeddingProvider extends EmbeddingProvider {
constructor(opts = {}) {
super();
this.apiKey = opts.apiKey || process.env.OPENAI_API_KEY;
this.endpoint = opts.endpoint || 'https://api.openai.com/v1/embeddings';
this.model = opts.model || 'text-embedding-3-small';
this.dims = opts.dimensions || 1536;
}
dimensions() {
return this.dims;
}
async embed(text) {
if (!this.apiKey) {
throw new Error('OPENAI_API_KEY not set. Use --embedding=keyword or set OPENAI_API_KEY.');
}
const res = await fetch(this.endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer this.apiKey`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: this.model,
input: text,
dimensions: this.dims
})
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenAI embedding error res.status: err`);
}
const json = await res.json();
return json.data[0].embedding;
}
async embedBatch(texts) {
if (!this.apiKey) {
throw new Error('OPENAI_API_KEY not set. Use --embedding=keyword or set OPENAI_API_KEY.');
}
const res = await fetch(this.endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer this.apiKey`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: this.model,
input: texts,
dimensions: this.dims
})
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenAI embedding error res.status: err`);
}
const json = await res.json();
return json.data.map((item) => item.embedding);
}
}
module.exports = { KeywordEmbeddingProvider, OpenAiEmbeddingProvider };
FILE:src/retriever.js
/**
* retriever.js
* 关键词召回 + 向量相似度召回双层检索。
* ranker:余弦相似度
* 最终结果 = 关键词召回 ∪ 向量召回 → 去重排序
*/
const { normalizeConcept, STOPWORDS } = require('./utils/nlp');
/**
* @typedef {Object} RetrievalResult
* @property {string} fragmentId
* @property {string} documentId
* @property {string} documentTitle
* @property {number} score
* @property {string} text
* @property {'keyword'|'vector'|'hybrid'} source
*/
/**
* 计算余弦相似度
* @param {number[]} a
* @param {number[]} b
*/
function cosineSimilarity(a, b) {
if (a.length !== b.length) return 0;
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i += 1) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
const denom = Math.sqrt(na) * Math.sqrt(nb);
return denom === 0 ? 0 : dot / denom;
}
/**
* 关键词召回(从 fragment 列表)
*/
function keywordSearch(fragments, query, limit = 20) {
const terms = normalizeConcept(query)
.split(' ')
.filter(Boolean)
.filter((t) => !STOPWORDS.has(t));
if (terms.length === 0) return [];
return fragments
.map((frag) => {
const textNorm = normalizeConcept(frag.text);
let score = 0;
for (const term of terms) {
if (textNorm.includes(term)) score += 3;
if ((frag.conceptNames || []).includes(term)) score += 5;
}
return { frag, score };
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.frag.index - b.frag.index)
.slice(0, limit)
.map((item) => ({
fragmentId: item.frag.id,
documentId: item.frag.documentId,
documentTitle: item.frag.documentTitle || 'unknown',
score: item.score,
text: item.frag.text,
source: 'keyword'
}));
}
/**
* 向量相似度召回
* @param {object[]} fragments - 带 documentTitle
* @param {string} query
* @param {import('./embedding-providers/EmbeddingProvider')} embeddingProvider
* @param {number} limit
*/
async function vectorSearch(fragments, query, embeddingProvider, limit = 20) {
if (!embeddingProvider || fragments.length === 0) return [];
const texts = fragments.map((f) => f.text);
const [queryVec, fragVecs] = await Promise.all([
embeddingProvider.embed([query]),
embeddingProvider.embed(texts)
]);
const qv = queryVec[0];
return fragments
.map((frag, i) => ({
frag,
sim: cosineSimilarity(qv, fragVecs[i] || [])
}))
.filter((item) => item.sim > 0)
.sort((a, b) => b.sim - a.sim)
.slice(0, limit)
.map((item) => ({
fragmentId: item.frag.id,
documentId: item.frag.documentId,
documentTitle: item.frag.documentTitle || 'unknown',
score: item.sim,
text: item.frag.text,
source: 'vector'
}));
}
/**
* 合并关键词召回 + 向量召回,去重并排序
* @param {RetrievalResult[]} keywordResults
* @param {RetrievalResult[]} vectorResults
* @param {number} limit
*/
function mergeResults(keywordResults, vectorResults, limit = 10) {
const seen = new Map();
for (const r of [...keywordResults, ...vectorResults]) {
if (!seen.has(r.fragmentId)) {
seen.set(r.fragmentId, { ...r });
} else {
// 取最高分
const existing = seen.get(r.fragmentId);
existing.score = Math.max(existing.score, r.score);
existing.source = existing.source === r.source ? existing.source : 'hybrid';
}
}
return [...seen.values()]
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
/**
* 主检索入口
* @param {object} options
* @param {object[]} options.fragments - fragment 列表,需含 documentTitle
* @param {string} options.query
* @param {import('./embedding-providers/EmbeddingProvider')} [options.embeddingProvider]
* @param {number} [options.limit]
*/
async function retrieve({ fragments, query, embeddingProvider = null, limit = 10 }) {
const kw = keywordSearch(fragments, query, limit * 2);
const vec = embeddingProvider
? await vectorSearch(fragments, query, embeddingProvider, limit * 2)
: [];
return mergeResults(kw, vec, limit);
}
module.exports = {
keywordSearch,
vectorSearch,
mergeResults,
cosineSimilarity,
retrieve
};
FILE:src/storage-adapters/JsonStorageAdapter.js
/**
* JsonStorageAdapter
* 基于本地 JSON 文件的存储实现,保持向后兼容。
*/
const fs = require('fs');
const path = require('path');
const { StorageAdapter } = require('./StorageAdapter');
const ROOT = path.resolve(__dirname, '../..');
const DATA_DIR = path.join(ROOT, 'data');
const DB_PATH = path.join(DATA_DIR, 'workspace.json');
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function now() {
return new Date().toISOString();
}
class JsonStorageAdapter extends StorageAdapter {
constructor() {
super();
this._db = null;
}
_loadDb() {
ensureDir(DATA_DIR);
if (!fs.existsSync(DB_PATH)) {
const empty = this._emptyDb();
this._saveDb(empty);
return empty;
}
return JSON.parse(fs.readFileSync(DB_PATH, 'utf8'));
}
_saveDb(db) {
db.updatedAt = now();
ensureDir(DATA_DIR);
fs.writeFileSync(DB_PATH, JSON.stringify(db, null, 2), 'utf8');
}
_emptyDb() {
return { version: 1, createdAt: now(), updatedAt: now(), documents: [], fragments: [], concepts: [], links: [] };
}
async init() {
this._db = this._loadDb();
}
// --- Document ---
async saveDocument(doc) {
const db = this._loadDb();
const idx = db.documents.findIndex((d) => d.id === doc.id);
if (idx >= 0) db.documents[idx] = doc;
else db.documents.push(doc);
this._saveDb(db);
return doc;
}
async getDocument(id) {
const db = this._loadDb();
return db.documents.find((d) => d.id === id) || null;
}
async listDocuments() {
const db = this._loadDb();
return db.documents;
}
async deleteDocument(id) {
const db = this._loadDb();
db.documents = db.documents.filter((d) => d.id !== id);
this._saveDb(db);
}
// --- Fragment ---
async saveFragment(frag) {
const db = this._loadDb();
const idx = db.fragments.findIndex((f) => f.id === frag.id);
if (idx >= 0) db.fragments[idx] = frag;
else db.fragments.push(frag);
this._saveDb(db);
return frag;
}
async getFragment(id) {
const db = this._loadDb();
return db.fragments.find((f) => f.id === id) || null;
}
async listFragments(documentId) {
const db = this._loadDb();
return db.fragments.filter((f) => f.documentId === documentId);
}
async deleteFragmentsByDocument(documentId) {
const db = this._loadDb();
db.fragments = db.fragments.filter((f) => f.documentId !== documentId);
this._saveDb(db);
}
async saveFragments(fragments) {
const db = this._loadDb();
for (const frag of fragments) {
const idx = db.fragments.findIndex((f) => f.id === frag.id);
if (idx >= 0) db.fragments[idx] = frag;
else db.fragments.push(frag);
}
this._saveDb(db);
}
// --- Concept ---
async saveConcept(concept) {
const db = this._loadDb();
const idx = db.concepts.findIndex((c) => c.id === concept.id);
if (idx >= 0) db.concepts[idx] = concept;
else db.concepts.push(concept);
this._saveDb(db);
return concept;
}
async getConcept(id) {
const db = this._loadDb();
return db.concepts.find((c) => c.id === id) || null;
}
async listConcepts() {
const db = this._loadDb();
return db.concepts;
}
async upsertConcept(concept) {
const db = this._loadDb();
const idx = db.concepts.findIndex((c) => c.id === concept.id);
if (idx >= 0) db.concepts[idx] = concept;
else db.concepts.push(concept);
this._saveDb(db);
return concept;
}
async saveConcepts(concepts) {
const db = this._loadDb();
for (const c of concepts) {
const idx = db.concepts.findIndex((x) => x.id === c.id);
if (idx >= 0) db.concepts[idx] = c;
else db.concepts.push(c);
}
this._saveDb(db);
}
// --- Link ---
async saveLink(link) {
const db = this._loadDb();
const idx = db.links.findIndex((l) => l.id === link.id);
if (idx >= 0) db.links[idx] = link;
else db.links.push(link);
this._saveDb(db);
return link;
}
async getLinks(fromId, toId) {
const db = this._loadDb();
return db.links.filter(
(l) => (fromId ? l.fromId === fromId : true) && (toId ? l.toId === toId : true)
);
}
async deleteLinksByDocument(documentId) {
const db = this._loadDb();
db.links = db.links.filter((l) => l.documentId !== documentId);
this._saveDb(db);
}
async saveLinks(links) {
const db = this._loadDb();
for (const l of links) {
const idx = db.links.findIndex((x) => x.id === l.id);
if (idx >= 0) db.links[idx] = l;
else db.links.push(l);
}
this._saveDb(db);
}
// --- Full DB operations (for CLI compat) ---
async load() {
return this._loadDb();
}
async save(db) {
this._saveDb(db);
}
async close() {
// no-op for JSON
}
// --- Workspace ---
async clear() {
this._saveDb(this._emptyDb());
}
// --- Stats ---
getStats() {
const db = this._loadDb();
return {
documents: db.documents.length,
fragments: db.fragments.length,
concepts: db.concepts.length,
links: db.links.length,
updatedAt: db.updatedAt,
dbPath: DB_PATH
};
}
}
module.exports = { JsonStorageAdapter };
FILE:src/storage-adapters/SqliteStorageAdapter.js
/**
* SqliteStorageAdapter
* 基于 better-sqlite3 的 SQLite 存储实现。
* 建表语句 + 所有 StorageAdapter 方法的完整实现。
*/
const { StorageAdapter } = require('./StorageAdapter');
let sqlite3 = null;
try {
sqlite3 = require('better-sqlite3');
} catch {
// better-sqlite3 not installed — adapter will throw on init()
}
const ROOT = path => require('path').resolve(__dirname, '../..');
const DATA_DIR = path.join(ROOT, 'data');
const DB_PATH = path.join(DATA_DIR, 'db.sqlite');
function ensureDir(dir) {
require('fs').mkdirSync(dir, { recursive: true });
}
function now() {
return new Date().toISOString();
}
const SCHEMA = `
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'document',
title TEXT,
sourceType TEXT,
sourceUri TEXT,
importedAt TEXT,
status TEXT DEFAULT 'active',
text TEXT,
createdAt TEXT,
updatedAt TEXT
);
CREATE TABLE IF NOT EXISTS fragments (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'fragment',
documentId TEXT,
"index" INTEGER,
text TEXT,
summary TEXT,
conceptNames TEXT,
createdAt TEXT,
FOREIGN KEY (documentId) REFERENCES documents(id)
);
CREATE TABLE IF NOT EXISTS concepts (
id TEXT PRIMARY KEY,
type TEXT DEFAULT 'concept',
name TEXT,
normalizedName TEXT UNIQUE,
salience REAL,
createdAt TEXT
);
CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY,
type TEXT,
fromId TEXT,
fromType TEXT,
toId TEXT,
toType TEXT,
documentId TEXT,
score REAL,
createdAt TEXT,
FOREIGN KEY (documentId) REFERENCES documents(id)
);
CREATE TABLE IF NOT EXISTS fragment_vectors (
fragmentId TEXT PRIMARY KEY,
vector BLOB,
updatedAt TEXT,
FOREIGN KEY (fragmentId) REFERENCES fragments(id)
);
CREATE INDEX IF NOT EXISTS idx_fragments_documentId ON fragments(documentId);
CREATE INDEX IF NOT EXISTS idx_links_fromId ON links(fromId);
CREATE INDEX IF NOT EXISTS idx_links_toId ON links(toId);
CREATE INDEX IF NOT EXISTS idx_links_documentId ON links(documentId);
CREATE INDEX IF NOT EXISTS idx_concepts_normalizedName ON concepts(normalizedName);
`;
class SqliteStorageAdapter extends StorageAdapter {
constructor({ dbPath } = {}) {
super();
this._dbPath = dbPath || DB_PATH;
this._db = null;
}
async init() {
if (!sqlite3) {
throw new Error('better-sqlite3 is not installed. Run: npm install better-sqlite3');
}
ensureDir(require('path').dirname(this._dbPath));
this._db = sqlite3(this._dbPath);
this._db.pragma('journal_mode = WAL');
this._db.exec(SCHEMA);
}
_run(sql, params = []) {
try {
return this._db.prepare(sql).run(...params);
} catch (e) {
throw new Error(`SQLite run error: e.message | sql: sql`);
}
}
_all(sql, params = []) {
try {
return this._db.prepare(sql).all(...params);
} catch (e) {
throw new Error(`SQLite all error: e.message | sql: sql`);
}
}
_get(sql, params = []) {
try {
return this._db.prepare(sql).get(...params);
} catch (e) {
throw new Error(`SQLite get error: e.message | sql: sql`);
}
}
// --- Document ---
async saveDocument(doc) {
this._run(
`INSERT OR REPLACE INTO documents (id,type,title,sourceType,sourceUri,importedAt,status,text,createdAt,updatedAt)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
[doc.id, doc.type || 'document', doc.title || '', doc.sourceType || '', doc.sourceUri || '',
doc.importedAt || now(), doc.status || 'active', doc.text || '',
doc.createdAt || now(), now()]
);
return doc;
}
async getDocument(id) {
const row = this._get('SELECT * FROM documents WHERE id = ?', [id]);
return row ? this._mapDoc(row) : null;
}
async listDocuments() {
return this._all('SELECT * FROM documents ORDER BY importedAt DESC').map(this._mapDoc);
}
async deleteDocument(id) {
this._run('DELETE FROM documents WHERE id = ?', [id]);
}
// --- Fragment ---
async saveFragment(frag) {
this._run(
`INSERT OR REPLACE INTO fragments (id,type,documentId,"index",text,summary,conceptNames,createdAt)
VALUES (?,?,?,?,?,?,?,?)`,
[frag.id, 'fragment', frag.documentId, frag.index, frag.text, frag.summary || frag.text.slice(0, 100),
JSON.stringify(frag.conceptNames || []), frag.createdAt || now()]
);
return frag;
}
async getFragment(id) {
const row = this._get('SELECT * FROM fragments WHERE id = ?', [id]);
return row ? this._mapFrag(row) : null;
}
async listFragments(documentId) {
return this._all('SELECT * FROM fragments WHERE documentId = ? ORDER BY "index" ASC', [documentId])
.map(this._mapFrag);
}
async deleteFragmentsByDocument(documentId) {
this._run('DELETE FROM fragments WHERE documentId = ?', [documentId]);
}
async saveFragments(fragments) {
const stmt = this._db.prepare(
`INSERT OR REPLACE INTO fragments (id,type,documentId,"index",text,summary,conceptNames,createdAt)
VALUES (?,?,?,?,?,?,?,?)`
);
const insertMany = this._db.transaction((items) => {
for (const frag of items) {
stmt.run(frag.id, 'fragment', frag.documentId, frag.index, frag.text,
frag.summary || frag.text.slice(0, 100), JSON.stringify(frag.conceptNames || []),
frag.createdAt || now());
}
});
insertMany(fragments);
return fragments;
}
// --- Concept ---
async saveConcept(concept) {
this._run(
`INSERT OR REPLACE INTO concepts (id,type,name,normalizedName,salience,createdAt)
VALUES (?,?,?,?,?,?)`,
[concept.id, 'concept', concept.name, concept.normalizedName, concept.salience || 0,
concept.createdAt || now()]
);
return concept;
}
async getConcept(id) {
const row = this._get('SELECT * FROM concepts WHERE id = ?', [id]);
return row ? this._mapConcept(row) : null;
}
async listConcepts() {
return this._all('SELECT * FROM concepts ORDER BY normalizedName ASC').map(this._mapConcept);
}
async upsertConcept(concept) {
return this.saveConcept(concept);
}
async saveConcepts(concepts) {
const stmt = this._db.prepare(
`INSERT OR REPLACE INTO concepts (id,type,name,normalizedName,salience,createdAt) VALUES (?,?,?,?,?,?)`
);
const insertMany = this._db.transaction((items) => {
for (const c of items) {
stmt.run(c.id, 'concept', c.name, c.normalizedName, c.salience || 0, c.createdAt || now());
}
});
insertMany(concepts);
return concepts;
}
// --- Link ---
async saveLink(link) {
this._run(
`INSERT OR REPLACE INTO links (id,type,fromId,fromType,toId,toType,documentId,score,createdAt)
VALUES (?,?,?,?,?,?,?,?,?)`,
[link.id, link.type, link.fromId, link.fromType, link.toId, link.toType,
link.documentId, link.score || 0, link.createdAt || now()]
);
return link;
}
async getLinks(fromId, toId) {
let sql = 'SELECT * FROM links WHERE 1=1';
const params = [];
if (fromId) { sql += ' AND fromId = ?'; params.push(fromId); }
if (toId) { sql += ' AND toId = ?'; params.push(toId); }
return this._all(sql, params);
}
async deleteLinksByDocument(documentId) {
this._run('DELETE FROM links WHERE documentId = ?', [documentId]);
}
async saveLinks(links) {
const stmt = this._db.prepare(
`INSERT OR REPLACE INTO links (id,type,fromId,fromType,toId,toType,documentId,score,createdAt)
VALUES (?,?,?,?,?,?,?,?,?)`
);
const insertMany = this._db.transaction((items) => {
for (const l of items) {
stmt.run(l.id, l.type, l.fromId, l.fromType, l.toId, l.toType,
l.documentId, l.score || 0, l.createdAt || now());
}
});
insertMany(links);
return links;
}
// --- Vector ---
async saveFragmentVector(fragmentId, vector) {
const buf = Buffer.from(JSON.stringify(vector));
this._run(
`INSERT OR REPLACE INTO fragment_vectors (fragmentId,vector,updatedAt) VALUES (?,?,?)`,
[fragmentId, buf, now()]
);
}
async getFragmentVector(fragmentId) {
const row = this._get('SELECT vector FROM fragment_vectors WHERE fragmentId = ?', [fragmentId]);
if (!row) return null;
return JSON.parse(row.vector);
}
async listFragmentVectors() {
return this._all('SELECT fragmentId, vector FROM fragment_vectors').map((r) => ({
fragmentId: r.fragmentId,
vector: JSON.parse(r.vector)
}));
}
// --- Full DB operations (for CLI compat) ---
async load() {
const docs = this._all('SELECT * FROM documents ORDER BY importedAt DESC').map(this._mapDoc);
const frags = this._all('SELECT * FROM fragments ORDER BY "index" ASC').map(this._mapFrag);
const concepts = this._all('SELECT * FROM concepts ORDER BY normalizedName ASC').map(this._mapConcept);
const links = this._all('SELECT * FROM links');
const updatedAt = this._get('SELECT MAX(updatedAt) as t FROM documents UNION ALL SELECT MAX(updatedAt) as t FROM fragments')?.t || new Date().toISOString();
return { version: 1, documents: docs, fragments: frags, concepts, links, updatedAt };
}
async save(db) {
await this.clear();
if (db.documents) for (const d of db.documents) await this.saveDocument(d);
if (db.fragments) for (const f of db.fragments) await this.saveFragment(f);
if (db.concepts) for (const c of db.concepts) await this.saveConcept(c);
if (db.links) for (const l of db.links) await this.saveLink(l);
}
async close() {
if (this._db) { this._db.close(); this._db = null; }
}
// --- Workspace ---
async clear() {
this._run('DELETE FROM fragment_vectors');
this._run('DELETE FROM links');
this._run('DELETE FROM fragments');
this._run('DELETE FROM concepts');
this._run('DELETE FROM documents');
}
getStats() {
return {
documents: this._get('SELECT COUNT(*) as n FROM documents')?.n || 0,
fragments: this._get('SELECT COUNT(*) as n FROM fragments')?.n || 0,
concepts: this._get('SELECT COUNT(*) as n FROM concepts')?.n || 0,
links: this._get('SELECT COUNT(*) as n FROM links')?.n || 0,
dbPath: this._dbPath
};
}
// --- Mappers ---
_mapDoc(row) {
return { ...row };
}
_mapFrag(row) {
return { ...row, conceptNames: JSON.parse(row.conceptNames || '[]') };
}
_mapConcept(row) {
return { ...row };
}
}
module.exports = { SqliteStorageAdapter };
FILE:src/storage-adapters/StorageAdapter.js
/**
* StorageAdapter Interface
* 所有 storage adapter 必须实现此接口契约。
*/
class StorageAdapter {
/**
* @returns {Promise<void>}
*/
async init() {
throw new Error('Not implemented');
}
// --- Document ---
async saveDocument(doc) {
throw new Error('Not implemented');
}
async getDocument(id) {
throw new Error('Not implemented');
}
async listDocuments() {
throw new Error('Not implemented');
}
async deleteDocument(id) {
throw new Error('Not implemented');
}
// --- Fragment ---
async saveFragment(frag) {
throw new Error('Not implemented');
}
async getFragment(id) {
throw new Error('Not implemented');
}
async listFragments(documentId) {
throw new Error('Not implemented');
}
async deleteFragmentsByDocument(documentId) {
throw new Error('Not implemented');
}
// --- Concept ---
async saveConcept(concept) {
throw new Error('Not implemented');
}
async getConcept(id) {
throw new Error('Not implemented');
}
async listConcepts() {
throw new Error('Not implemented');
}
async upsertConcept(concept) {
throw new Error('Not implemented');
}
// --- Link ---
async saveLink(link) {
throw new Error('Not implemented');
}
async getLinks(fromId, toId) {
throw new Error('Not implemented');
}
async deleteLinksByDocument(documentId) {
throw new Error('Not implemented');
}
// --- Bulk ---
async saveFragments(fragments) {
throw new Error('Not implemented');
}
async saveConcepts(concepts) {
throw new Error('Not implemented');
}
async saveLinks(links) {
throw new Error('Not implemented');
}
// --- Workspace ---
async clear() {
throw new Error('Not implemented');
}
}
module.exports = { StorageAdapter };
FILE:src/storage-adapters/index.js
/**
* storage-adapters/index.js
* Factory: 根据配置返回对应的 storage adapter 实例。
*/
const { JsonStorageAdapter } = require('./JsonStorageAdapter');
const { SqliteStorageAdapter } = require('./SqliteStorageAdapter');
function createStorageAdapter(type = 'json', options = {}) {
switch (type) {
case 'json':
return new JsonStorageAdapter(options);
case 'sqlite':
return new SqliteStorageAdapter(options);
default:
throw new Error(`Unknown storage adapter type: type. Use 'json' or 'sqlite'.`);
}
}
module.exports = { createStorageAdapter, JsonStorageAdapter, SqliteStorageAdapter };
FILE:src/utils/nlp.js
/**
* nlp.js - 文本规范化工具
*/
const STOPWORDS = new Set([
'the','and','for','that','this','with','from','into','your','have','will','not','are','was','were','can','could','should','about','what','when','where','which','while','than','then','them','they','their','there','here','also','more','most','some','such','using','used','use','make','made','over','under','very','just','only','each','been','being','does','did','done','how','why','our','you','its','his','her','she','him','who','has','had','but','too','via','per','one','two','three',
'我们','你们','他们','这个','那个','一个','一种','可以','需要','如果','因为','所以','以及','进行','通过','没有','不是','就是','如何','什么','为什么','时候','今天','已经','还有','对于','一些','这种','那些','并且','或者','主要','用户','系统','功能','能力','作为','实现','相关','用于'
]);
function normalizeConcept(text) {
if (!text) return '';
return text
.toLowerCase()
.replace(/[\u2018\u2019'"""''()()【】\[\],.:;!?/\\]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
module.exports = { normalizeConcept, STOPWORDS };
FILE:tests/smoke-test.js
#!/usr/bin/env node
/**
* LinkMind Smoke Test - Phase 2
* 覆盖: storage adapters + embedding providers + retriever
*/
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import { pathToFileURL } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SKILL_ROOT = path.resolve(__dirname, '..');
process.chdir(SKILL_ROOT);
function run(cmd) {
console.log(`\n$ cmd`);
return JSON.parse(execSync(cmd, { encoding: 'utf8' }));
}
function check(label, cond) {
if (cond) {
console.log(` ✓ label`);
} else {
console.error(` ✗ FAIL: label`);
process.exitCode = 1;
}
}
// Import phase-2 modules via dynamic import
const srcDir = path.join(SKILL_ROOT, 'src');
async function runTests() {
console.log('=== LinkMind Smoke Test (Phase 2) ===\n');
// Phase 1: CLI commands
console.log('--- Phase 1: CLI Commands ---');
const reset = run('node dist/index.js reset');
check('reset returns ok', reset.ok === true);
const empty = run('node dist/index.js status');
check('empty workspace has 0 documents', empty.documents === 0);
const ingest = run('node dist/index.js ingest --file examples/sample-note.md --title "Sample Note"');
check('ingest returns documentId', !!ingest.documentId);
check('ingest creates fragments', ingest.fragmentsCreated >= 1);
const after = run('node dist/index.js status');
check('has 1 document', after.documents === 1);
check('has fragments', after.fragments >= 1);
const q1 = run('node dist/index.js query --q "knowledge"');
check('query "knowledge" finds evidence', q1.evidence && q1.evidence.length >= 1);
check('query "knowledge" returns answer', !!q1.answer);
const q2 = run('node dist/index.js query --q "xyznonexistentterm12345"');
check('query no-match returns empty evidence', q2.evidence && q2.evidence.length === 0);
const q3 = run('node dist/index.js query --q "knowledge" --limit 1');
check('query with limit respects limit', q3.evidence && q3.evidence.length <= 1);
// Phase 2: Storage Adapters
console.log('\n--- Phase 2: Storage Adapters ---');
const JsonStorageAdapterMod = await import(pathToFileURL(path.join(srcDir, 'storage-adapters/JsonStorageAdapter.js')).href);
const StorageAdapterMod = await import(pathToFileURL(path.join(srcDir, 'storage-adapters/StorageAdapter.js')).href);
const { JsonStorageAdapter } = JsonStorageAdapterMod;
const { StorageAdapter } = StorageAdapterMod;
const jsAdapter = new JsonStorageAdapter();
await jsAdapter.init();
await jsAdapter.clear();
check('JsonStorageAdapter.init() ok', true);
const testDoc = {
id: 'doc_test1',
type: 'document',
title: 'Test Doc',
sourceType: 'note',
sourceUri: '/test/path.md',
importedAt: new Date().toISOString(),
status: 'active',
text: 'This is a test document for storage adapter testing.'
};
await jsAdapter.saveDocument(testDoc);
const retrieved = await jsAdapter.getDocument('doc_test1');
check('JsonStorageAdapter.saveDocument + getDocument', retrieved && retrieved.title === 'Test Doc');
const docs = await jsAdapter.listDocuments();
check('JsonStorageAdapter.listDocuments', docs && docs.length >= 1);
const testFrag = {
id: 'frag_test1',
type: 'fragment',
documentId: 'doc_test1',
index: 0,
text: 'This is a test fragment.',
summary: 'This is a test',
conceptNames: ['test'],
createdAt: new Date().toISOString()
};
await jsAdapter.saveFragment(testFrag);
const frag = await jsAdapter.getFragment('frag_test1');
check('JsonStorageAdapter.saveFragment + getFragment', frag && frag.text.includes('test fragment'));
const frags = await jsAdapter.listFragments('doc_test1');
check('JsonStorageAdapter.listFragments', frags && frags.length >= 1);
const testConcept = {
id: 'concept_test1',
type: 'concept',
name: 'TestConcept',
normalizedName: 'testconcept',
salience: 0.5,
createdAt: new Date().toISOString()
};
await jsAdapter.saveConcept(testConcept);
const concepts = await jsAdapter.listConcepts();
check('JsonStorageAdapter.listConcepts', concepts && concepts.some(c => c.normalizedName === 'testconcept'));
const testLink = {
id: 'link_test1',
type: 'mentions',
fromId: 'frag_test1',
fromType: 'fragment',
toId: 'concept_test1',
toType: 'concept',
documentId: 'doc_test1',
score: 1,
createdAt: new Date().toISOString()
};
await jsAdapter.saveLink(testLink);
const links = await jsAdapter.getLinks('frag_test1', null);
check('JsonStorageAdapter.saveLink + getLinks', links && links.some(l => l.id === 'link_test1'));
// test bulk save
await jsAdapter.clear();
await jsAdapter.saveDocument(testDoc);
await jsAdapter.saveFragments([testFrag]);
await jsAdapter.saveConcepts([testConcept]);
await jsAdapter.saveLinks([testLink]);
const afterBulk = await jsAdapter.listFragments('doc_test1');
check('JsonStorageAdapter.saveFragments bulk', afterBulk && afterBulk.length >= 1);
await jsAdapter.clear();
const emptyAfterClear = await jsAdapter.listDocuments();
check('JsonStorageAdapter.clear()', emptyAfterClear.length === 0);
// StorageAdapter interface
check('StorageAdapter is a class', typeof StorageAdapter === 'function');
// Phase 2: Embedding Providers
console.log('\n--- Phase 2: Embedding Providers ---');
const MockProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/MockProvider.js')).href);
const OpenAICompatibleProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/OpenAICompatibleProvider.js')).href);
const EmbeddingProviderMod = await import(pathToFileURL(path.join(srcDir, 'embedding-providers/EmbeddingProvider.js')).href);
const { MockProvider } = MockProviderMod;
const { OpenAICompatibleProvider } = OpenAICompatibleProviderMod;
const { EmbeddingProvider } = EmbeddingProviderMod;
const mock = new MockProvider({ dimension: 128 });
check('MockProvider.dimension', mock.dimension === 128);
check('MockProvider.name', mock.name === 'mock');
const [vec] = await mock.embed(['hello world']);
check('MockProvider.embed returns vector', Array.isArray(vec) && vec.length === 128);
check('MockProvider.vector is L2-normalized', Math.abs(vec.reduce((s, v) => s + v * v, 0) - 1) < 0.01);
const [vec2] = await mock.embed(['hello world']);
check('MockProvider caches results', vec2 === vec);
const differentText = await mock.embed(['different text']);
check('MockProvider different text yields different vector', differentText[0] !== vec);
const openai = new OpenAICompatibleProvider({ baseURL: 'https://api.example.com/v1', apiKey: 'test-key', model: 'test-model', dimension: 256 });
check('OpenAICompatibleProvider.dimension', openai.dimension === 256);
check('OpenAICompatibleProvider.name includes model', openai.name.includes('test-model'));
check('OpenAICompatibleProvider.name includes baseURL hint', openai.name.includes('openai-compatible'));
check('EmbeddingProvider is a class', typeof EmbeddingProvider === 'function');
// Phase 2: Retriever
console.log('\n--- Phase 2: Retriever ---');
const retrieverMod = await import(pathToFileURL(path.join(srcDir, 'retriever.js')).href);
const { keywordSearch, vectorSearch, mergeResults, cosineSimilarity, retrieve } = retrieverMod;
check('cosineSimilarity identical vectors = 1', Math.abs(cosineSimilarity([0.5, 0.5, 0.5, 0.5], [0.5, 0.5, 0.5, 0.5]) - 1) < 0.0001);
check('cosineSimilarity orthogonal = 0', Math.abs(cosineSimilarity([1, 0, 0], [0, 1, 0])) < 0.0001);
const testFrags = [
{ id: 'f1', documentId: 'd1', documentTitle: 'Doc 1', index: 0, text: 'knowledge graph is useful', conceptNames: ['knowledge', 'graph'] },
{ id: 'f2', documentId: 'd1', documentTitle: 'Doc 1', index: 1, text: 'machine learning and AI', conceptNames: ['machine', 'learning'] },
{ id: 'f3', documentId: 'd2', documentTitle: 'Doc 2', index: 0, text: 'knowledge base systems', conceptNames: ['knowledge', 'base'] }
];
const kw = keywordSearch(testFrags, 'knowledge');
check('keywordSearch finds knowledge fragments', kw.length >= 2);
check('keywordSearch assigns score > 0', kw.every(r => r.score > 0));
check('keywordSearch source=keyword', kw.every(r => r.source === 'keyword'));
const vecResults = await vectorSearch(testFrags, 'knowledge graph', mock);
check('vectorSearch returns results', Array.isArray(vecResults));
check('vectorSearch source=vector', vecResults.every(r => r.source === 'vector'));
const merged = mergeResults(kw, vecResults, 5);
check('mergeResults deduplicates', merged.length <= kw.length + vecResults.length);
check('mergeResults returns array', Array.isArray(merged));
check('mergeResults items have score', merged.every(r => typeof r.score === 'number'));
const kwOnly = await retrieve({ fragments: testFrags, query: 'knowledge', limit: 5 });
check('retrieve keyword-only works', kwOnly.length >= 1);
const hybrid = await retrieve({ fragments: testFrags, query: 'knowledge', embeddingProvider: mock, limit: 5 });
check('retrieve hybrid works', hybrid.length >= 1);
check('retrieve hybrid may include vector source', hybrid.some(r => r.source === 'vector' || r.source === 'hybrid'));
console.log('\n=== Smoke Test Complete ===');
if (process.exitCode === 1) {
console.log('RESULT: FAILED');
process.exit(1);
} else {
console.log('RESULT: PASSED');
}
}
runTests().catch((err) => {
console.error('Test error:', err);
process.exit(1);
});
Cross-Platform Shopping Decision Agent / 全网购物决策官. Compares prices, quality, and risk across 淘宝/拼多多/京东/一号店/唯品会 and recommends the best platform for a given pr...
---
name: dealpilot
slug: dealpilot
version: 0.1.0
description: |
Cross-Platform Shopping Decision Agent / 全网购物决策官.
Compares prices, quality, and risk across 淘宝/拼多多/京东/一号店/唯品会
and recommends the best platform for a given product and context.
---
# DealPilot / 全网购物决策官
你是**全网购物决策官**。
你的任务不是罗列商品参数,而是在用户给出购物需求后,**帮他在多个平台中找到最优解**,
并给出可执行的购买建议。
## 产品定位
DealPilot 是一个跨平台购物比价决策引擎,覆盖:淘宝、拼多多、京东、一号店、唯品会。
核心价值:
- **跨平台比价**:同商品多平台实时价格对比
- **风险评估**:卖家信誉、售后保障、假货风险
- **时机判断**:当前是否是最佳购买时机
- **推荐收敛**:给出明确平台建议和理由
## 使用场景
用户可能会说:
- "帮我看看这个产品在哪个平台买最划算"
- "对比一下京东和拼多多买这个"
- "我想买 X,百元以内,哪个平台靠谱"
- "这个价格是不是好价,要不要等"
- "淘宝和唯品会哪个更靠谱"
## 输入 schema(统一需求格式)
```typescript
interface ShoppingRequest {
// 商品信息
product?: string; // 商品名称/关键词
productUrl?: string; // 商品链接(可多平台)
category?: string; // 品类
// 用户偏好
budget?: PriceRange; // 预算范围
priorities?: string[]; // 优先级:["价格", "品质", "速度", "售后"]
quantity?: number; // 购买数量
// 场景
scenario?: "personal" | "gift" | "resale"; // 自用/送礼/转卖
urgency?: "low" | "medium" | "high"; // 紧急程度
// 平台范围(默认全部)
platforms?: Platform[];
// 约束
mustHave?: string[]; // 必须有的特性/服务
mustAvoid?: string[]; // 必须避免的
}
type Platform = "taobao" | "pdd" | "jd" | "yhd" | "vip";
type PriceRange = { min?: number; max?: number; };
```
## 输出 schema(统一决策报告)
```typescript
interface DecisionReport {
// 推荐决策
recommendedPlatform: Platform;
recommendedUrl?: string;
recommendedPrice?: number;
// 决策理由
reasons: string[]; // 为什么要选这个平台
// 风险提示
risks: RiskItem[]; // 当前方案的风险点
// 替代方案
alternatives: AltPlatform[]; // 其他可选平台,排序
// 时机建议
timingAdvice: TimingAdvice;
// 对比摘要(表格)
comparison: PlatformSummary[];
// 最终结论(可执行)
conclusion: string;
}
interface RiskItem {
level: "low" | "medium" | "high";
description: string;
mitigation?: string; // 如何降低风险
}
interface AltPlatform {
platform: Platform;
score: number; // 0-100
price?: number;
keyReason: string;
}
interface TimingAdvice {
verdict: "buy_now" | "wait" | "compare_first";
reason: string;
waitDays?: number; // 建议等多少天
betterPeriod?: string; // "618", "双十一" 等
}
interface PlatformSummary {
platform: Platform;
price?: number;
quality: "low" | "medium" | "high";
shipping: "slow" | "medium" | "fast";
afterSales: "weak" | "medium" | "strong";
authenticity: "risky" | "medium" | "safe";
score: number;
}
```
## 决策引擎流程
```
用户需求输入
↓
[需求解析] → 识别商品、预算、偏好、场景
↓
[平台搜索] → 各平台并行搜索(stub 阶段返回 mock 数据)
↓
[价格抓取] → 提取各平台到手价(含优惠/拼团等)
↓
[质量评估] → 店铺信誉、评论、售后标识
↓
[风险评分] → 假货/售后/物流风险量化
↓
[决策推荐] → 综合评分 + 时机判断
↓
[报告生成] → DecisionReport 输出
```
## 平台适配层
每个平台适配器需实现以下接口:
```typescript
interface PlatformAdapter {
platform: Platform;
// 搜索商品
search(query: string, options?: SearchOptions): Promise<SearchResult[]>;
// 获取商品详情
getProductDetail(url: string): Promise<ProductInfo>;
// 计算最终到手价(含优惠/拼团/百亿补贴等)
calcFinalPrice(product: ProductInfo): Promise<FinalPrice>;
// 评估店铺/卖家风险
assessSeller(url: string): Promise<SellerRisk>;
// 平台特性判断
getPlatformTraits(): PlatformTraits;
}
interface SearchOptions {
category?: string;
minPrice?: number;
maxPrice?: number;
sortBy?: "price" | "sales" | "rating";
}
interface FinalPrice {
rawPrice: number;
finalPrice: number;
discountDesc: string[]; // ["百亿补贴-100", "拼团-30"]
isSubsidized: boolean; // 是否官方补贴
}
interface PlatformTraits {
strength: string[]; // ["价格最低", "自营可信"]
weakness: string[]; // ["物流较慢", "退换麻烦"]
bestFor: string[]; // ["标准品", "电子产品"]
worstFor: string[]; // ["生鲜", "奢侈品"]
}
```
## 平台优先级参考
| 维度 | 淘宝 | 拼多多 | 京东 | 一号店 | 唯品会 |
|------|------|--------|------|--------|--------|
| 价格 | 中 | 最低 | 高 | 中低 | 中 |
| 品质 | 中 | 偏低 | 高 | 中 | 中 |
| 物流 | 中 | 慢 | 最快 | 快 | 中 |
| 售后 | 中 | 弱 | 强 | 中 | 中 |
| 正品 | 中 | 风险高 | 最安全 | 中 | 中 |
## 当前状态
- **搜索/比价**:stub(返回 mock 数据)
- **价格抓取**:stub
- **店铺评估**:stub
- **决策推荐**:stub(固定返回 mock 推荐)
- **时机判断**:stub
下一步由平台组接入真实适配器。
## 相关 Skill
- `taobao` - 淘宝平台适配
- `pdd-shopping` - 拼多多平台适配
- `jingdong` - 京东平台适配
- `shopping-advisor` - 单商品决策逻辑
## 当前状态 (v0.1.0)
## 当前状态 (v0.1.0)
**MVP 骨架版本** - 平台适配器为 stub 实现,返回 mock 数据。
### 已实现
- ✅ 输入/输出 schema 定义
- ✅ 决策引擎流程定义
- ✅ 平台适配器接口定义
- ✅ Mock 决策输出
- ✅ 自测脚本 (`scripts/test-stub.js`)
### 待实现
- 🔄 各平台真实搜索/价格抓取
- 🔄 真实店铺风险评估
- 🔄 实时价格/优惠信息
- 🔄 接入现有平台 skill (taobao, pdd-shopping, jingdong)
## 使用说明
### 本地测试
```bash
cd /Users/jianghaidong/.openclaw/skills/dealpilot
node scripts/test-stub.js
```
### 在 OpenClaw 中调用
```javascript
// 示例:调用 dealpilot 进行决策
const { normalizeRequest } = require('./scripts/normalize.js');
const { decide } = require('./scripts/decide.js');
const { formatReport } = require('./scripts/analyze.js');
async function runDealPilot(product, budget) {
const request = normalizeRequest({ product, budget });
const report = await decide(request);
return formatReport(report);
}
// 示例调用
runDealPilot("蓝牙耳机", { max: 200 }).then(console.log);
```
### 平台适配器状态
| 平台 | 适配器 | 状态 | 备注 |
|------|--------|------|------|
| 淘宝 | `TaobaoAdapter` | stub | 待接入 `taobao` skill |
| 拼多多 | `PddAdapter` | stub | 待接入 `pdd-shopping` skill |
| 京东 | `JdAdapter` | stub | 待接入 `jingdong` skill |
| 一号店 | `YhdAdapter` | stub | 待接入 `yhd` skill |
| 唯品会 | `VipAdapter` | stub | 待接入 `vip` skill |
## 开发指南
### 目录结构
```
dealpilot/
├── SKILL.md # 技能定义
├── clawhub.json # 技能元数据
├── package.json # 依赖配置
├── README.md # 项目说明
├── engine/ # 决策引擎
│ ├── router.ts # 路由层(决策入口)
│ └── types.ts # 类型定义
├── platforms/ # 平台适配器
│ ├── base.ts # 适配器基类
│ ├── taobao.ts # 淘宝适配器
│ ├── pdd.ts # 拼多多适配器
│ ├── jd.ts # 京东适配器
│ ├── yhd.ts # 一号店适配器
│ └── vip.ts # 唯品会适配器
└── scripts/ # 工具脚本
├── normalize.js # 需求解析
├── decide.js # 决策调用
├── analyze.js # 报告格式化
└── test-stub.js # 自测脚本
```
### 下一步开发
1. **接入真实平台数据** - 将各平台适配器的 stub 方法替换为真实 API 调用
2. **价格抓取** - 实现各平台实时价格获取
3. **风险评估** - 接入店铺信誉、评论分析
4. **时机判断** - 结合促销日历、历史价格
5. **集成测试** - 端到端测试真实商品决策
## 相关 Skill
- `taobao` - 淘宝平台搜索与详情
- `pdd-shopping` - 拼多多百亿补贴/拼团策略
- `jingdong` - 京东自营/物流分析
- `shopping-advisor` - 单商品深度分析
FILE:README.md
# DealPilot / 全网购物决策官
> Cross-Platform Shopping Decision Agent
**DealPilot** 帮助用户在多个电商平台(淘宝、拼多多、京东、一号店、唯品会)之间做出最优购买决策。
## 核心能力
- 🔍 **跨平台比价** — 一次查询,获取同商品在多个平台的实时价格
- ⚖️ **综合评分** — 价格、品质、物流、售后多维度评分
- ⚠️ **风险提示** — 假货风险、售后风险、物流风险量化
- ⏰ **时机判断** — 当前是否是最佳购买时机
- ✅ **明确推荐** — 收敛到具体平台和购买链接
## 工作流程
1. 用户输入购物需求(商品、预算、偏好、场景)
2. 并行查询各平台适配器
3. 综合评分 + 风险评估
4. 输出决策报告(含推荐平台、理由、风险、替代方案)
## 快速开始
```
用户:帮我看看 iPhone 15 在哪个平台买最划算
Agent → 调用 DealPilot skill
→ 各平台并行搜索
→ 价格抓取 + 店铺评估
→ 决策推荐
→ 输出报告
```
## 与相关 Skill 的关系
| Skill | 用途 |
|-------|------|
| `shopping-advisor` | 单商品/单平台深度分析 |
| `taobao` | 淘宝搜索与详情 |
| `pdd-shopping` | 拼多多百亿补贴/拼团策略 |
| `jingdong` | 京东自营/物流分析 |
| `DealPilot` | 跨平台比价决策(主 skill)|
## 当前版本
v0.1.0 — 骨架版本,平台适配器为 stub 实现。
预计可用功能:
- 输入/输出 schema 定义 ✓
- 决策引擎流程定义 ✓
- 平台适配器接口定义 ✓
- Mock 决策输出 ✓
FILE:RUNNING.md
# DealPilot - 运行与测试指南
## 技能状态
- **版本**: v0.1.0 (MVP 骨架)
- **状态**: 骨架完成,返回 mock 数据
- **平台适配器**: 全部为 stub 实现
## 快速测试
### 1. 运行自测脚本
```bash
cd /Users/jianghaidong/.openclaw/skills/dealpilot
node scripts/test-stub.js
```
输出示例:
```
=== DealPilot 骨架自测 ===
输入规范化: { ... }
--- 运行决策引擎 ---
决策报告生成: ✓
推荐平台: pdd
结论: 针对"蓝牙耳机",综合价格、品质、售后风险...
```
### 2. 在代码中调用
```javascript
const { normalizeRequest } = require('./scripts/normalize.js');
const { decide } = require('./scripts/decide.js');
const { formatReport } = require('./scripts/analyze.js');
async function test() {
const request = normalizeRequest({
product: "iPhone 15",
budget: { max: 5000 },
urgency: "medium"
});
const report = await decide(request);
console.log(formatReport(report));
}
test();
```
## 目录结构
```
dealpilot/
├── SKILL.md # 技能定义(OpenClaw 使用)
├── clawhub.json # 技能元数据
├── package.json # 依赖配置
├── README.md # 项目说明
├── RUNNING.md # 本文件
├── engine/ # 决策引擎
│ ├── router.ts # 路由层(决策入口)
│ └── types.ts # 类型定义
├── platforms/ # 平台适配器
│ ├── base.ts # 适配器基类
│ ├── taobao.ts # 淘宝适配器(stub)
│ ├── pdd.ts # 拼多多适配器(stub)
│ ├── jd.ts # 京东适配器(stub)
│ ├── yhd.ts # 一号店适配器(stub)
│ └── vip.ts # 唯品会适配器(stub)
└── scripts/ # 工具脚本
├── normalize.js # 需求解析
├── decide.js # 决策调用
├── analyze.js # 报告格式化
└── test-stub.js # 自测脚本
```
## 开发下一步
### 阶段 1: 接入真实数据
1. **淘宝平台** - 接入 `taobao` skill 的搜索功能
2. **拼多多平台** - 接入 `pdd-shopping` skill 的百亿补贴检查
3. **京东平台** - 接入 `jingdong` skill 的自营商品搜索
4. **一号店/唯品会** - 实现基础搜索功能
### 阶段 2: 增强功能
1. **价格抓取** - 实时价格、优惠券、满减计算
2. **风险评估** - 店铺信誉、评论分析、假货识别
3. **时机判断** - 促销日历、历史价格趋势
4. **个性化推荐** - 基于用户历史偏好的权重调整
### 阶段 3: 生产就绪
1. **错误处理** - 网络超时、平台 API 变化
2. **缓存策略** - 价格缓存、搜索结果缓存
3. **性能优化** - 并行搜索、结果聚合
4. **监控告警** - 成功率监控、异常检测
## 验证清单
- [x] `openclaw skills check` 可识别 dealpilot
- [x] `test-stub.js` 运行成功
- [x] SKILL.md 包含完整使用说明
- [x] 所有平台适配器骨架就位
- [ ] 接入真实平台数据
- [ ] 端到端测试真实商品
- [ ] 性能测试与优化
## 故障排除
1. **TypeScript 编译问题**: 当前使用 .js 文件,如需 TypeScript 编译需添加 tsconfig.json
2. **路径问题**: 确保从 `/Users/jianghaidong/.openclaw/skills/dealpilot/` 目录运行脚本
3. **模块导入**: 使用 ES module 语法 (`import`),确保 `package.json` 有 `"type": "module"`
## 相关链接
- [SKILL.md](./SKILL.md) - 技能完整文档
- [PM 进度跟踪](../../shared/pm-progress/dealpilot.json) - 项目状态
- [GitHub 仓库](https://github.com/harrylabsj/dealpilot) - 源代码
FILE:clawhub.json
{
"name": "dealpilot",
"version": "0.1.0",
"description": "全网购物决策官 - 跨平台比价决策引擎,覆盖淘宝/拼多多/京东/一号店/唯品会",
"keywords": ["dealpilot", "shopping", "price-comparison", "cross-platform", "taobao", "pdd", "jd", "yhd", "vip"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/dealpilot"
}
FILE:engine/router.ts
// DealPilot 决策引擎 - 路由层
// 负责接收 ShoppingRequest,分发到各平台适配器,汇总结果
import type { ShoppingRequest, DecisionReport, Platform } from "./types";
// STUB: 各平台适配器注册表
// 下一阶段替换为真实适配器
const platformAdapters: Record<Platform, any> = {
taobao: { platform: "taobao", stub: true },
pdd: { platform: "pdd", stub: true },
jd: { platform: "jd", stub: true },
yhd: { platform: "yhd", stub: true },
vip: { platform: "vip", stub: true },
};
export async function runDecisionEngine(
request: ShoppingRequest
): Promise<DecisionReport> {
const platforms: Platform[] = request.platforms || ["taobao", "pdd", "jd", "yhd", "vip"];
// STUB: 返回模拟决策报告
// 下一阶段替换为真实的多平台查询和评分逻辑
return buildMockReport(request, platforms);
}
function buildMockReport(request: ShoppingRequest, platforms: Platform[]): DecisionReport {
const product = request.product || "未知商品";
return {
recommendedPlatform: "pdd",
recommendedPrice: 89,
reasons: [
"拼多多百亿补贴后价格最低",
"该品类在拼多多有官方补贴标识",
"退货包运费降低试错成本",
],
risks: [
{
level: "medium",
description: "拼多多第三方卖家品质参差不齐",
mitigation: "选择有「退货包运费」和「假一赔十」标识的卖家",
},
],
alternatives: [
{ platform: "jd", score: 82, price: 109, keyReason: "京东物流最快,自营品质有保障" },
{ platform: "taobao", score: 75, price: 95, keyReason: "淘宝价格适中,售后比拼多多稳定" },
{ platform: "yhd", score: 70, price: 92, keyReason: "一号店日常用品价格稳定" },
{ platform: "vip", score: 65, price: 88, keyReason: "唯品会品牌特卖可能有低价" },
],
timingAdvice: {
verdict: "buy_now",
reason: "该商品当前价格已接近历史低价,暂无大型促销节点临近",
},
comparison: platforms.map((p) => ({
platform: p,
price: p === "pdd" ? 89 : p === "jd" ? 109 : p === "taobao" ? 95 : p === "yhd" ? 92 : 88,
quality: p === "jd" ? "high" : p === "taobao" ? "medium" : "medium",
shipping: p === "jd" ? "fast" : p === "yhd" ? "fast" : p === "pdd" ? "slow" : "medium",
afterSales: p === "jd" ? "strong" : p === "taobao" ? "medium" : "weak",
authenticity: p === "jd" ? "safe" : p === "pdd" ? "risky" : "medium",
score: p === "pdd" ? 85 : p === "jd" ? 82 : p === "taobao" ? 75 : 70,
})),
conclusion: `针对"product",综合价格、品质、售后风险,推荐在**拼多多百亿补贴**购买,到手价约89元。若更看重品质和售后保障,建议选**京东自营**,到手价约109元。`,
};
}
FILE:engine/types.ts
// DealPilot 类型定义
export type Platform = "taobao" | "pdd" | "jd" | "yhd" | "vip";
export interface PriceRange {
min?: number;
max?: number;
}
export interface ShoppingRequest {
product?: string;
productUrl?: string;
category?: string;
budget?: PriceRange;
priorities?: string[];
quantity?: number;
scenario?: "personal" | "gift" | "resale";
urgency?: "low" | "medium" | "high";
platforms?: Platform[];
mustHave?: string[];
mustAvoid?: string[];
}
export interface RiskItem {
level: "low" | "medium" | "high";
description: string;
mitigation?: string;
}
export interface AltPlatform {
platform: Platform;
score: number;
price?: number;
keyReason: string;
}
export interface TimingAdvice {
verdict: "buy_now" | "wait" | "compare_first";
reason: string;
waitDays?: number;
betterPeriod?: string;
}
export interface PlatformSummary {
platform: Platform;
price?: number;
quality: "low" | "medium" | "high";
shipping: "slow" | "medium" | "fast";
afterSales: "weak" | "medium" | "strong";
authenticity: "risky" | "medium" | "safe";
score: number;
}
export interface DecisionReport {
recommendedPlatform: Platform;
recommendedUrl?: string;
recommendedPrice?: number;
reasons: string[];
risks: RiskItem[];
alternatives: AltPlatform[];
timingAdvice: TimingAdvice;
comparison: PlatformSummary[];
conclusion: string;
}
export interface SearchOptions {
category?: string;
minPrice?: number;
maxPrice?: number;
sortBy?: "price" | "sales" | "rating";
}
export interface FinalPrice {
rawPrice: number;
finalPrice: number;
discountDesc: string[];
isSubsidized: boolean;
}
export interface PlatformTraits {
strength: string[];
weakness: string[];
bestFor: string[];
worstFor: string[];
}
FILE:package.json
{
"name": "dealpilot",
"version": "0.1.0",
"description": "Cross-Platform Shopping Decision Agent",
"type": "module",
"main": "index.js",
"scripts": {
"test": "node scripts/test-stub.js"
}
}
FILE:platforms/base.ts
// 平台适配器基类
// 所有平台适配器需实现 PlatformAdapter 接口
import type { Platform, PlatformTraits } from "../engine/types";
export interface PlatformAdapter {
platform: Platform;
search(query: string, options?: any): Promise<any[]>;
getProductDetail(url: string): Promise<any>;
calcFinalPrice(product: any): Promise<any>;
assessSeller(url: string): Promise<any>;
getPlatformTraits(): PlatformTraits;
}
export abstract class BaseAdapter implements PlatformAdapter {
abstract platform: Platform;
async search(query: string, options?: any): Promise<any[]> {
throw new Error("STUB: 实现平台搜索");
}
async getProductDetail(url: string): Promise<any> {
throw new Error("STUB: 实现商品详情获取");
}
async calcFinalPrice(product: any): Promise<any> {
throw new Error("STUB: 实现最终价格计算");
}
async assessSeller(url: string): Promise<any> {
throw new Error("STUB: 实现卖家风险评估");
}
getPlatformTraits(): PlatformTraits {
return {
strength: [],
weakness: [],
bestFor: [],
worstFor: [],
};
}
}
FILE:platforms/jd.ts
// 京东平台适配器
// 预留接驳现有 jingdong skill
import { BaseAdapter } from "./base";
import type { PlatformTraits } from "../engine/types";
export class JdAdapter extends BaseAdapter {
platform = "jd" as const;
getPlatformTraits(): PlatformTraits {
return {
strength: ["物流最快", "自营可信", "售后最强"],
weakness: ["价格最高", "品类不如淘宝全"],
bestFor: ["电子产品", "急需品", "礼品", "正品要求高的商品"],
worstFor: ["低价日用品", "非标品"],
};
}
// TODO: 接入现有 jingdong skill 的搜索和自营商品能力
// import { jdSearch, jdDetail } from "~/jingdong/scripts";
}
FILE:platforms/pdd.ts
// 拼多多平台适配器
// 预留接驳现有 pdd-shopping skill
import { BaseAdapter } from "./base";
import type { PlatformTraits } from "../engine/types";
export class PddAdapter extends BaseAdapter {
platform = "pdd" as const;
getPlatformTraits(): PlatformTraits {
return {
strength: ["价格最低", "百亿补贴", "拼团优惠"],
weakness: ["物流慢", "售后弱", "假货风险较高"],
bestFor: ["标准品", "低价日用品", "愿意等待"],
worstFor: ["电子产品", "奢侈品", "急需品", "礼品"],
};
}
// TODO: 接入现有 pdd-shopping skill 的搜索和百亿补贴检查能力
// import { pddSearch, checkSubsidy } from "~/pdd-shopping/scripts";
}
FILE:platforms/taobao.ts
// 淘宝平台适配器
// 预留接驳现有 taobao skill
import { BaseAdapter } from "./base";
import type { PlatformTraits } from "../engine/types";
export class TaobaoAdapter extends BaseAdapter {
platform = "taobao" as const;
getPlatformTraits(): PlatformTraits {
return {
strength: ["品类最全", "有旗舰店", "售后相对稳定"],
weakness: ["价格不是最低", "存在刷单风险"],
bestFor: ["非标品", "个性化商品", "品牌商品"],
worstFor: ["低价标准品", "需要快速到手"],
};
}
// TODO: 接入现有 taobao skill 的搜索和详情能力
// import { taobaoSearch } from "~/taobao/scripts/search";
// import { taobaoDetail } from "~/taobao/scripts/detail";
}
FILE:platforms/vip.ts
// 唯品会平台适配器
import { BaseAdapter } from "./base";
import type { PlatformTraits } from "../engine/types";
export class VipAdapter extends BaseAdapter {
platform = "vip" as const;
getPlatformTraits(): PlatformTraits {
return {
strength: ["品牌特卖", "正品保障", "价格有折扣"],
weakness: ["品类有限", "需要挑选", "退换不如京东"],
bestFor: ["品牌服装", "品牌鞋包", "品牌美妆"],
worstFor: ["电子产品", "日用品", "食品"],
};
}
}
FILE:platforms/yhd.ts
// 一号店平台适配器
import { BaseAdapter } from "./base";
import type { PlatformTraits } from "../engine/types";
export class YhdAdapter extends BaseAdapter {
platform = "yhd" as const;
getPlatformTraits(): PlatformTraits {
return {
strength: ["日常用品价格稳定", "物流较快"],
weakness: ["品类有限", "知名度不如主流平台"],
bestFor: ["日常用品", "食品", "快消品"],
worstFor: ["电子产品", "服装", "非标品"],
};
}
}
FILE:scripts/analyze.js
// 分析输出脚本
// 将 DecisionReport 整理为用户可读格式
/**
* @param {any} report
* @returns {string}
*/
export function formatReport(report) {
let out = "";
out += `## 推荐平台:report.recommendedPlatform.toUpperCase()\n`;
if (report.recommendedPrice) out += `参考价格:¥report.recommendedPrice\n`;
out += `\n### 决策理由\n`;
report.reasons.forEach((r) => (out += `- r\n`));
if (report.risks.length > 0) {
out += `\n### 风险提示\n`;
report.risks.forEach((r) => {
out += `- [r.level.toUpperCase()] r.description`;
if (r.mitigation) out += ` → r.mitigation`;
out += "\n";
});
}
out += `\n### 时机建议:report.timingAdvice.verdict === "wait" ? "建议等待" : "建议先比较"\n`;
out += `report.timingAdvice.reason\n`;
if (report.alternatives.length > 0) {
out += `\n### 替代方案\n`;
report.alternatives.forEach((a) => {
out += `- **a.platform**(a.score分)a.price ? `¥${a.price` : ""}:a.keyReason\n`;
});
}
out += `\n---\n**结论**:report.conclusion`;
return out;
}
FILE:scripts/decide.js
// 决策脚本
// 调用决策引擎生成 DecisionReport
/**
* @param {any} request
* @returns {Promise<DecisionReport>}
*/
export async function decide(request) {
// STUB: 调用 mock 决策引擎
const { runDecisionEngine } = await import("../engine/router.ts");
return runDecisionEngine(request);
}
FILE:scripts/normalize.js
// 需求解析脚本
// 将用户的自然语言输入规范化为 ShoppingRequest
/**
* @param {any} raw
* @returns {ShoppingRequest}
*/
export function normalizeRequest(raw) {
// STUB: 简单解析,下一阶段接入 LLM 做意图识别
return {
product: raw.product || raw.q || raw.query || raw.item,
budget: raw.budget || raw.priceRange,
priorities: raw.priorities || ["价格", "品质"],
quantity: raw.quantity || 1,
scenario: raw.scenario || "personal",
urgency: raw.urgency || "medium",
platforms: raw.platforms,
mustHave: raw.mustHave,
mustAvoid: raw.mustAvoid,
};
}
FILE:scripts/test-stub.js
// 本地自测脚本 - 验证骨架是否可跑通
import { normalizeRequest } from "./normalize.js";
import { decide } from "./decide.js";
import { formatReport } from "./analyze.js";
async function test() {
console.log("=== DealPilot 骨架自测 ===\n");
// 测试1: 需求解析
const raw = { product: "蓝牙耳机", budget: { max: 200 }, urgency: "high" };
const request = normalizeRequest(raw);
console.log("输入规范化:", JSON.stringify(request, null, 2));
// 测试2: 决策引擎
console.log("\n--- 运行决策引擎 ---");
const report = await decide(request);
console.log("决策报告生成:", report ? "✓" : "✗");
console.log("推荐平台:", report?.recommendedPlatform);
console.log("结论:", report?.conclusion);
// 测试3: 输出格式化
console.log("\n--- 格式化输出 ---");
const formatted = formatReport(report);
console.log(formatted);
console.log("\n=== 自测完成 ===");
}
test().catch(console.error);
FILE:skill.json
{
"name": "dealpilot",
"version": "0.1.0",
"description": "全网购物决策官 - 跨平台比价决策引擎,覆盖淘宝/拼多多/京东/一号店/唯品会",
"keywords": ["dealpilot", "shopping", "price-comparison", "cross-platform", "taobao", "pdd", "jd", "yhd", "vip"],
"author": "harrylabsj",
"license": "MIT",
"repository": "https://github.com/harrylabsj/dealpilot",
"language": ["en", "zh"],
"tags": ["shopping", "price-comparison", "taobao", "pdd", "jd"],
"createdAt": "2026-04-04",
"updatedAt": "2026-04-05"
}
Change intelligence skill that compares previous and current knowledge snapshots, surfaces what was newly added, which claims changed, what conclusions are n...
---
name: ChangeBrief
slug: changebrief
version: 1.0.1
description: Change intelligence skill that compares previous and current knowledge snapshots, surfaces what was newly added, which claims changed, what conclusions are now stale, which conflicts need a decision, and which three changes deserve immediate action. Use when the user asks "最近到底变了什么", wants a daily or weekly change brief, or needs an incremental layer on top of a knowledge base instead of another full summary.
metadata:
clawdbot:
emoji: "🧾"
requires:
bins: []
os: ["linux", "darwin", "win32"]
---
# ChangeBrief
One-line positioning:
`不是再读一遍所有资料,而是每天 30 秒知道真正变化了什么。`
ChangeBrief is not another knowledge base.
It is the incremental change layer that sits on top of notes, documents, meeting summaries,
research collections, and connector outputs.
Its job is to help the user answer:
- 这周新增了哪些重要信息
- 哪几份文档的说法变了
- 哪些旧结论现在可能失效
- 哪些冲突已经需要拍板
- 哪 3 个变化最值得马上行动
It should feel like a calm change-briefing officer for managers and operators: short, current,
and biased toward decision value rather than recap volume.
## Core Positioning
Default toward these outcomes:
- show only the delta that matters
- separate new information from unchanged background
- detect changed claims rather than repeating the whole source set
- flag stale conclusions and broken assumptions
- surface decision-worthy conflicts
- end with the few changes that deserve action now
Do not drift into:
- a long summary of all inputs
- a passive changelog dump
- a document diff with no judgment
- a knowledge graph that still leaves the user asking what changed
## Relationship To Knowledge Skills
Think of the stack like this:
- Knowledge Connector: bring knowledge in, connect it, and search it
- DecisionDeck: compress knowledge into a decision-ready one-pager
- NextFromKnowledge: turn knowledge into action
- ChangeBrief: tell you what is newly different before you read everything again
Use this boundary:
- if the user needs import, retrieval, or relationship discovery, use Knowledge Connector first
- if the user needs a boss-ready one-page decision brief, use DecisionDeck
- if the user needs the next move, use NextFromKnowledge
- if the user mainly needs to know what changed since last time, use ChangeBrief
## When To Use It
Use this skill when the user says things like:
- `最近到底变了什么`
- `帮我做本周变化简报`
- `这几份文档和上周相比哪里不一样`
- `哪些旧结论已经不成立了`
- `告诉我哪些变化值得我现在处理`
- `不要重读所有资料,只看增量`
- `把前后两版内容压缩成管理者能快速看的变化摘要`
It is especially strong when the user already has:
- last week's and this week's notes
- previous and current docs
- meeting summaries from two periods
- connector outputs from two snapshots
- pasted bullets that represent before and after
## Inputs It Can Work From
Common inputs:
- previous and current snapshots of notes or docs
- weekly reports
- release notes
- roadmap updates
- policy or pricing revisions
- research summaries that changed over time
- meeting notes from two adjacent cycles
For compact heuristics on changed claims, stale conclusions, and priority ranking, read
[references/change-signals.md](references/change-signals.md).
## Core Workflow
1. Compare two snapshots.
Decide what counts as:
- newly added
- unchanged
- removed
- same topic but changed claim
2. Rank signal, not volume.
Prefer changes that affect:
- risk
- time line
- customer impact
- resource allocation
- product scope
- validity of prior conclusions
3. Mark stale conclusions.
Call out when an older claim, assumption, default, or plan is no longer safe to repeat.
4. Surface decision pressure.
If the new change implies tradeoffs, blockers, or owner confusion, say that explicitly.
5. End with action priority.
Compress the whole delta into the three changes that most deserve immediate attention.
## Decision Rules
### Delta Over Recap
Do not spend most of the output describing what stayed the same.
The user is here for change signal, not background retelling.
### Changed Claims Matter More Than Added Sentences
A single changed statement can matter more than ten new bullets.
Prioritize:
- scope reversals
- time line changes
- newly introduced blockers
- numbers that materially changed
- customer or stakeholder demands that move priority
### Stale Conclusions Must Be Named Plainly
Good phrasing:
- `这条旧结论现在不宜继续复述。`
- `之前的判断需要改写,因为底层条件已经变了。`
- `旧版本的说法在新快照里已经不再安全。`
### Conflict Should Trigger A Call, Not A Shrug
When the before and after snapshots now point in different directions, say what needs to be
decided and why it matters now.
### Keep The Brief Manager-Grade
The default output should be fast to scan:
- one-line headline
- important additions
- changed claims
- stale conclusions
- conflicts needing a call
- top 3 action-worthy changes
If the user wants shorter output, compress further. If they want raw structure, return the
underlying change analysis as JSON.
FILE:CHANGELOG.md
# Changelog
## 1.0.1
Release theme: 修正首发发布路径,确保线上版本对应真正的 ChangeBrief skill 包。
What changed:
- 使用绝对路径重新发布 `changebrief`,避免误把上层 workspace 打进同名 slug
- 保持产品定位、CLI、测试和文档不变,修正线上包内容与本地仓库一致
Suggested one-line changelog:
- Fix the published package path so ChangeBrief ships the correct skill files and metadata.
## 1.0.0
Release theme: 给知识线补上一层真正可用的“增量变化层”。
What changed:
- 新建 `ChangeBrief` 产品线定位,明确它不是再做一个知识库,而是做“最近到底变了什么”的变化简报层
- 新增最小 CLI,支持 `brief`、`changes`、`invalidations`、`conflicts`、`priorities`、`analyze`
- 实现前后快照对比逻辑,用于识别重要新增、说法变化、失效结论和决策冲突
- 补齐 `README`、`RELEASE`、`package.json`、测试和发布脚本,整理成可发布仓库
Suggested one-line changelog:
- Initial release of ChangeBrief: compare previous and current knowledge snapshots to surface what changed, what became stale, and what deserves action now.
FILE:README.md
# ChangeBrief
不是再读一遍所有资料,而是每天 30 秒知道真正变化了什么。
`ChangeBrief` 是“变化简报官”这条产品线的第一版可发布仓库。它承接 `Knowledge Connector` 之后的下一步,但不再强调“接更多资料”,而是强调“最近到底变了什么”。
它解决的是更真实的一层问题:
- 这周新增了哪些重要信息
- 哪几份文档的说法变了
- 哪些旧结论已经不成立了
- 哪些冲突已经需要拍板
- 哪 3 个变化最值得现在行动
## 为什么这条线值得做
知识线很容易走到一个尴尬状态:
- 连接了很多资料
- 也能做总结
- 但不知道最近真正变化了什么
ChangeBrief 补的就是这个“增量变化层”。
它不是新的知识仓库,也不是长摘要器,而是一个管理者会愿意每天打开的变化工作台。
## 默认输出什么
它默认不产出长摘要,而是产出这种结构:
- `变化一句话`
- `这周新增了哪些重要信息`
- `哪几处说法变了`
- `哪些旧结论可能失效`
- `哪些冲突需要拍板`
- `最值得立刻行动的 3 个变化`
## 适合的输入
- 上周版 / 本周版文档
- 上一轮 / 当前轮会议纪要
- 两个时间点的研究摘要
- 旧版 / 新版政策、定价、路线图
- Knowledge Connector 导出的两次结果
- 用户自己整理的 before / after bullet points
## 核心命令
```bash
cb brief --before-file last-week.md --after-file this-week.md
cb changes --before-file v1.md --after-file v2.md
cb invalidations --before-file previous.md --after-file current.md
cb conflicts --before-file previous.md --after-file current.md
cb priorities --before-file previous.md --after-file current.md
cb analyze --before-file previous.md --after-file current.md --json
```
这些命令分别对应:
- 默认变化简报
- 重要新增变化
- 旧结论失效提示
- 需要拍板的冲突
- 最值得马上行动的变化优先级
- 原始结构化分析结果
## 和相邻 skill 的边界
- `Knowledge Connector`:负责导入、连接、搜索、关系理解
- `DecisionDeck`:负责把资料压缩成一页式决策 brief
- `NextFromKnowledge`:负责把知识推进成下一步动作
- `ChangeBrief`:负责告诉你“和上次相比,真正重要的变化是什么”
如果用户说的是“不要再总结背景,只告诉我最近变了什么”,更适合 `ChangeBrief`。
## 典型问题
- “帮我做本周变化简报”
- “这几份文档跟上周比哪里变了”
- “哪些旧结论已经不安全了”
- “什么变化最值得我现在处理”
- “哪些冲突已经需要老板拍板”
- “不要重读所有资料,只看增量”
## 建议安装名
```bash
clawhub install changebrief
```
## 仓库结构
```text
changebrief/
├── SKILL.md
├── README.md
├── CHANGELOG.md
├── RELEASE.md
├── clawhub.json
├── package.json
├── .gitignore
├── agents/openai.yaml
├── references/change-signals.md
├── bin/cli.js
├── src/index.js
├── test/test.js
└── scripts/publish.sh
```
## 本地验证
```bash
node test/test.js
node bin/cli.js brief --before-file before.md --after-file after.md
```
## 发布
```bash
npm run publish:clawhub
```
或直接执行:
```bash
sh ./scripts/publish.sh
```
## 一句话卖点
给知识仓库补上一层“增量变化 intelligence”:新增、变更、失效、冲突和立即行动,一次讲清楚。
FILE:RELEASE.md
# ChangeBrief Release Notes
## Short Description
把前后两版知识快照压缩成一份变化简报:新增重点、说法变化、失效结论、待拍板冲突和立即行动优先级。
## Marketplace Card Copy
Title:
- ChangeBrief
Short description:
- 变化简报官:不是重读所有资料,而是直接告诉你最近真正变了什么
Install hook:
- 给 Knowledge Connector 补上“增量变化层”,每天 30 秒看懂新增、变更、失效和行动优先级
## Announcement Copy
ChangeBrief 不是再做一个知识仓库。
它解决的是知识线最容易被忽略的一层:
- 资料连接越来越多
- 总结也做了不少
- 但用户还是不知道最近真正变化了什么
这一版把事情收敛到 5 个高价值结果:
- 这周新增了哪些重要信息
- 哪些说法和上次不一样了
- 哪些旧结论已经不安全
- 哪些冲突已经需要拍板
- 哪 3 个变化最值得现在行动
它和 Knowledge Connector、DecisionDeck、NextFromKnowledge 的关系也很清晰:
- Knowledge Connector 负责 bring knowledge in
- ChangeBrief 负责 surface the delta
- DecisionDeck 负责 make the one-page brief
- NextFromKnowledge 负责 decide the next move
## Suggested Tags
- latest
- knowledge
- change
- delta
- briefing
- productivity
## Suggested Repo Name
- `openclaw-skill-changebrief`
## Publish Command
```bash
clawhub publish /absolute/path/to/changebrief \
--slug changebrief \
--name "ChangeBrief" \
--version "1.0.1" \
--changelog "Fix the published package path so ChangeBrief ships the correct skill files and metadata." \
--tags "knowledge,change,delta,briefing,decision-support,productivity"
```
FILE:agents/openai.yaml
interface:
display_name: "ChangeBrief"
short_description: "变化简报官:对比前后知识快照,告诉你最近真正变了什么"
default_prompt: "Use $changebrief to compare the previous and current knowledge snapshots, surface important additions, changed claims, stale conclusions, conflicts that need a decision, and the top three changes worth immediate action, and answer in Chinese."
FILE:bin/cli.js
#!/usr/bin/env node
const ChangeBrief = require('../src/index.js');
const pkg = require('../package.json');
function parseArgs(argv) {
const options = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith('-')) {
options._.push(arg);
continue;
}
const key = arg.replace(/^-+/, '');
const next = argv[i + 1];
const hasValue = typeof next !== 'undefined' && !next.startsWith('-');
options[key] = hasValue ? next : true;
if (hasValue) {
i += 1;
}
}
return options;
}
function printHelp() {
console.log(`ChangeBrief pkg.version
Usage:
cb brief --before-file last.md --after-file current.md
cb changes --before-file v1.md --after-file v2.md
cb invalidations --before-file previous.md --after-file current.md
cb conflicts --before-file previous.md --after-file current.md
cb priorities --before-file previous.md --after-file current.md
cb analyze --before-file previous.md --after-file current.md --json
Commands:
brief Generate the full change brief
changes Show important additions
invalidations Show stale conclusions and what replaced them
conflicts Show change-driven conflicts that need a decision
priorities Rank the top changes worth immediate action
analyze Return the raw structured analysis
Options:
--before-file <path> Read the previous snapshot from a file
--after-file <path> Read the current snapshot from a file
--before-text <text> Read the previous snapshot from inline text
--after-text <text> Read the current snapshot from inline text
--json Print JSON output
--help Show this help
--version Show version
`);
}
function writeOutput(engine, result, asJson) {
if (asJson) {
console.log(JSON.stringify(result, null, 2));
return;
}
console.log(engine.render(result));
}
async function main() {
const argv = process.argv.slice(2);
const command = argv[0];
if (!command || command === '--help' || command === '-h') {
printHelp();
return;
}
if (command === '--version' || command === '-v') {
console.log(pkg.version);
return;
}
const options = parseArgs(argv.slice(1));
const engine = new ChangeBrief();
try {
switch (command) {
case 'brief':
writeOutput(engine, engine.brief(options), options.json);
return;
case 'changes':
writeOutput(engine, engine.changes(options), options.json);
return;
case 'invalidations':
writeOutput(engine, engine.invalidations(options), options.json);
return;
case 'conflicts':
writeOutput(engine, engine.conflicts(options), options.json);
return;
case 'priorities':
writeOutput(engine, engine.priorities(options), options.json);
return;
case 'analyze':
console.log(JSON.stringify(engine.analyze(options), null, 2));
return;
default:
throw new Error(`Unknown command: command`);
}
} catch (error) {
console.error(`Error: error.message`);
process.exit(1);
}
}
main();
FILE:clawhub.json
{
"name": "changebrief",
"version": "1.0.1",
"description": "变化简报官 ChangeBrief - 对比前后知识快照,提炼新增重点、说法变化、失效结论、待拍板冲突和立即行动优先级",
"keywords": [
"changebrief",
"change-intelligence",
"knowledge-delta",
"weekly-brief",
"stale-conclusions",
"management-brief",
"decision-support",
"knowledge",
"productivity"
],
"author": "openclaw",
"license": "MIT",
"repository": "https://github.com/harrylabsj/openclaw-skill-changebrief"
}
FILE:package.json
{
"name": "changebrief",
"version": "1.0.1",
"description": "ChangeBrief - compare previous and current knowledge snapshots to surface important additions, changed claims, stale conclusions, conflicts, and immediate priorities",
"main": "src/index.js",
"bin": {
"changebrief": "./bin/cli.js",
"cb": "./bin/cli.js"
},
"scripts": {
"start": "node bin/cli.js",
"test": "node test/test.js",
"publish:clawhub": "sh ./scripts/publish.sh"
},
"keywords": [
"changebrief",
"change-intelligence",
"knowledge-delta",
"weekly-brief",
"stale-conclusions",
"change-detection",
"decision-support",
"knowledge-connector",
"productivity"
],
"author": "openclaw",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
},
"repository": {
"type": "git",
"url": "https://github.com/harrylabsj/openclaw-skill-changebrief"
},
"bugs": {
"url": "https://github.com/harrylabsj/openclaw-skill-changebrief/issues"
},
"homepage": "https://github.com/harrylabsj/openclaw-skill-changebrief#readme"
}
FILE:references/change-signals.md
# Change Signals
Use these heuristics when deciding whether a delta deserves to be highlighted.
## 1. Important Addition
Treat a new statement as high value when it changes:
- customer demand
- revenue, cost, conversion, or growth
- risk, compliance, or security
- launch timing or roadmaps
- scope, owner, or prioritization
## 2. Changed Claim
Treat two statements as the same topic with a changed claim when:
- they discuss the same object, product, feature, audience, or workstream
- but the numbers, timing, direction, or recommendation changed
Typical examples:
- `4 月底上线` -> `5 月中旬上线`
- `不需要法务审批` -> `需要先完成法务审批`
- `主要问题是步骤太多` -> `主要问题是价值表达不清`
## 3. Stale Conclusion
Mark an older statement as stale when:
- it was a conclusion, default, assumption, or plan
- and the new snapshot weakens or contradicts it
Good labels:
- old conclusion no longer safe
- underlying condition changed
- should be rewritten before it is repeated
## 4. Conflict Needing A Call
Escalate to a decision when the new delta implies:
- a changed time line
- a new blocker
- a new customer requirement
- a scope tradeoff
- unclear owner or priority
The goal is not to prove logical inconsistency.
The goal is to say: this change now creates management pressure and needs a call.
## 5. Top Priority Changes
Rank higher when a change affects:
- legal, compliance, or security risk
- customer commitments
- launch timing
- a previous executive conclusion
- what the team should do this week
FILE:scripts/publish.sh
#!/usr/bin/env sh
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
VERSION="$(node -e "process.stdout.write(require(process.argv[1]).version)" "$ROOT/package.json")"
CHANGELOG="$(node -e "const fs=require('fs'); const text=fs.readFileSync(process.argv[1], 'utf8'); const match=text.match(/Suggested one-line changelog:\\n- (.+)/); process.stdout.write(match ? match[1] : 'Initial release of ChangeBrief.');" "$ROOT/CHANGELOG.md")"
clawhub publish "$ROOT" \
--slug changebrief \
--name "ChangeBrief" \
--version "$VERSION" \
--changelog "$CHANGELOG" \
--tags "knowledge,change,delta,briefing,decision-support,productivity"
FILE:src/index.js
const fs = require('fs');
const STOP_WORDS = new Set([
'the',
'and',
'for',
'with',
'this',
'that',
'from',
'into',
'onto',
'have',
'has',
'had',
'was',
'were',
'are',
'is',
'been',
'being',
'will',
'would',
'should',
'could',
'a',
'an',
'of',
'to',
'in',
'on',
'at',
'by',
'we',
'it',
'its',
'our',
'their',
'目前',
'当前',
'现在',
'这个',
'那个',
'这周',
'上周',
'本周',
'本月',
'已经',
'以及',
'还有',
'然后',
'一个'
]);
const TOPIC_IGNORE = new Set([
'需要',
'完成',
'改为',
'计划',
'新增',
'反复',
'指出',
'之前',
'现在',
'问题',
'核心',
'当前',
'本周',
'这周',
'上周',
'已经',
'必须'
]);
class ChangeBrief {
readText(filePath, inlineText, label) {
if (filePath) {
return fs.readFileSync(filePath, 'utf-8');
}
if (typeof inlineText === 'string' && inlineText.trim()) {
return inlineText;
}
throw new Error(`Please provide --label-file or --label-text`);
}
readPair(input = {}) {
return {
before: this.readText(input['before-file'], input['before-text'], 'before'),
after: this.readText(input['after-file'], input['after-text'], 'after')
};
}
normalizeText(text) {
return String(text || '').replace(/\r\n/g, '\n').trim();
}
unique(items) {
const seen = new Set();
const output = [];
for (const item of items) {
const value = String(item || '').trim();
if (!value) {
continue;
}
if (seen.has(value)) {
continue;
}
seen.add(value);
output.push(value);
}
return output;
}
shorten(text, maxLength = 68) {
const value = String(text || '').trim().replace(/\s+/g, ' ');
if (value.length <= maxLength) {
return value;
}
return `value.slice(0, maxLength - 3)...`;
}
normalizeStatementKey(line) {
return this.normalizeText(line)
.toLowerCase()
.replace(/\s+/g, ' ')
.replace(/[,。;;!??!、,::"'`]/g, '')
.trim();
}
collectStatements(text) {
const normalized = this.normalizeText(text);
const pieces = normalized
.split('\n')
.flatMap((line) => line.split(/[。!??!;;,,]/g))
.map((line) => line.replace(/^\s*[-*•\d.)]+\s*/, '').trim())
.map((line) => line.replace(/\s+/g, ' ').trim())
.filter((line) => line.length >= 2);
return this.unique(pieces);
}
extractTokens(text) {
const raw = String(text || '').toLowerCase().match(/[a-z0-9]+|[\u4e00-\u9fff]+/g) || [];
const tokens = [];
for (const chunk of raw) {
if (STOP_WORDS.has(chunk)) {
continue;
}
if (/^[\u4e00-\u9fff]+$/.test(chunk)) {
if (chunk.length <= 2) {
tokens.push(chunk);
continue;
}
tokens.push(chunk);
for (let i = 0; i < chunk.length - 1; i += 1) {
const bigram = chunk.slice(i, i + 2);
if (!STOP_WORDS.has(bigram)) {
tokens.push(bigram);
}
}
continue;
}
if (chunk.length >= 2) {
tokens.push(chunk);
}
}
return this.unique(tokens);
}
sharedTokens(tokensA, tokensB) {
const setB = new Set(tokensB);
return tokensA.filter((token) => setB.has(token));
}
similarity(metaA, metaB) {
const shared = this.sharedTokens(metaA.tokens, metaB.tokens);
const minLength = Math.max(1, Math.min(metaA.tokens.length || 1, metaB.tokens.length || 1));
let score = shared.length / minLength;
const strongShared = shared.filter((token) => token.length >= 3);
if (strongShared.length > 0) {
score += 0.18;
}
const a = metaA.line.toLowerCase();
const b = metaB.line.toLowerCase();
if (a.includes(b) || b.includes(a)) {
score += 0.12;
}
return Math.min(1, score);
}
scoreImportance(line, numbers) {
let score = 1;
if (/风险|合规|法务|审批|安全审查|blocker|blocked|risk|security|legal|incident|outage/i.test(line)) {
score += 4;
}
if (/上线|发布|beta|roadmap|路线图|排期|deadline|本周|本月|延期|改为|时间线|launch|release|timeline|ship/i.test(line)) {
score += 3;
}
if (/客户|用户|销售|营收|收入|转化|成本|预算|需求|模板|pricing|customer|user|revenue|budget|conversion/i.test(line)) {
score += 2;
}
if (/新增|新签|开始|加入|增加|支持|要求|必须|优先|立即|需要|变成|不再|失效|new|added|introduced|required|urgent|must/i.test(line)) {
score += 2;
}
if ((numbers || []).length > 0) {
score += 1;
}
return score;
}
scoreAction(line) {
let score = 0;
if (/先|立即|马上|安排|确认|更新|同步|修复|通知|决定|拍板|发起|补上|调整|回滚|推进|review|notify|update|decide|approve|fix|ship/i.test(line)) {
score += 3;
}
if (/需要|必须|本周|优先|blocker|risk|deadline|法务|审批|安全审查|客户要求|冲突/i.test(line)) {
score += 2;
}
return score;
}
classifyLine(line) {
const normalized = String(line || '').trim();
const lower = normalized.toLowerCase();
const numbers = normalized.match(/\d+(?:\.\d+)?(?:%|天|周|月|小时|h|d|w|m)?/g) || [];
const negative = /不支持|失败|延迟|延期|取消|下线|风险|阻塞|禁止|下降|不再|失效|blocker|blocked|delay|cancel|deprecated|risk/i.test(lower);
const positive = /支持|通过|上线|增长|完成|可用|允许|降低|缩短|稳定|更快|enable|pass|launch|improve|available/i.test(lower);
return {
line: normalized,
key: this.normalizeStatementKey(normalized),
tokens: this.extractTokens(normalized),
numbers,
importance: this.scoreImportance(normalized, numbers),
actionScore: this.scoreAction(normalized),
positive,
negative,
blocker: /风险|法务|合规|安全审查|审批|blocker|blocked|security|legal|incident|outage/i.test(lower),
needsDecision: /拍板|决定|取舍|冲突|待定|待确认|需要确认|需要决定|tradeoff|choose|decision/i.test(lower),
staleCandidate: /结论|假设|计划|预计|默认|无需|已确认|判断|路线图|roadmap|assumption|plan|default/i.test(lower),
timeline: /上线|发布|beta|roadmap|路线图|排期|deadline|本周|本月|月底|中旬|下周|timeline|launch|release|ship/i.test(lower),
audience: /客户|用户|销售|老板|团队|法务|安全|工程|设计|运营|大客户|customer|user|sales|team/i.test(lower)
};
}
hasNumberChange(beforeMeta, afterMeta) {
if (beforeMeta.numbers.length === 0 || afterMeta.numbers.length === 0) {
return false;
}
return beforeMeta.numbers.join('|') !== afterMeta.numbers.join('|');
}
hasPolarityFlip(beforeMeta, afterMeta) {
if (/无需|不需要|不必|不会|不能/.test(beforeMeta.line) && /需要|必须|先完成/.test(afterMeta.line)) {
return true;
}
if (/支持|允许|可用/.test(beforeMeta.line) && /不支持|禁止|下线/.test(afterMeta.line)) {
return true;
}
if (/延期|取消|不再/.test(beforeMeta.line) && /上线|恢复|重新启动/.test(afterMeta.line)) {
return true;
}
if (/上线|完成|可用|支持/.test(beforeMeta.line) && /延期|取消|blocker|风险|不再/.test(afterMeta.line)) {
return true;
}
return (beforeMeta.positive && afterMeta.negative) || (beforeMeta.negative && afterMeta.positive);
}
topicLabel(beforeMeta, afterMeta) {
const shared = this.sharedTokens(beforeMeta.tokens, afterMeta.tokens)
.filter((token) => token.length >= 2 && !TOPIC_IGNORE.has(token))
.sort((a, b) => b.length - a.length);
if (shared.length > 0) {
return shared[0];
}
return this.shorten(afterMeta.line, 24);
}
hasStrongSharedTopic(beforeMeta, afterMeta) {
return this.sharedTokens(beforeMeta.tokens, afterMeta.tokens)
.some((token) => token.length >= 4 && !TOPIC_IGNORE.has(token));
}
buildChangedPair(beforeMeta, afterMeta, similarity) {
const reasons = [];
if (this.hasNumberChange(beforeMeta, afterMeta)) {
reasons.push(beforeMeta.timeline || afterMeta.timeline ? '时间线或关键数字变了' : '关键数字变了');
}
if (this.hasPolarityFlip(beforeMeta, afterMeta)) {
reasons.push('判断方向反转');
}
if (!beforeMeta.blocker && afterMeta.blocker) {
reasons.push('出现了新的 blocker 或风险');
}
if (!beforeMeta.audience && afterMeta.audience) {
reasons.push('影响范围变大了');
}
if (reasons.length === 0) {
reasons.push('同一主题出现了新说法');
}
return {
before: beforeMeta.line,
after: afterMeta.line,
beforeMeta,
afterMeta,
topic: this.topicLabel(beforeMeta, afterMeta),
reasons,
similarity: Number(similarity.toFixed(2)),
importance: Math.max(beforeMeta.importance, afterMeta.importance)
};
}
matchChangedClaims(beforeOnly, afterOnly) {
const matchedPairs = [];
const usedBefore = new Set();
const additions = [];
const sortedAfter = [...afterOnly].sort((a, b) => b.importance - a.importance || b.actionScore - a.actionScore);
for (const afterMeta of sortedAfter) {
let bestIndex = -1;
let bestScore = 0;
for (let i = 0; i < beforeOnly.length; i += 1) {
if (usedBefore.has(i)) {
continue;
}
const beforeMeta = beforeOnly[i];
const score = this.similarity(beforeMeta, afterMeta);
if (score > bestScore) {
bestScore = score;
bestIndex = i;
}
}
const bestBefore = bestIndex >= 0 ? beforeOnly[bestIndex] : null;
const qualifies = bestBefore && (
bestScore >= 0.55 ||
(bestScore >= 0.33 && (this.hasNumberChange(bestBefore, afterMeta) || this.hasPolarityFlip(bestBefore, afterMeta))) ||
(bestScore >= 0.3 && Math.max(bestBefore.importance, afterMeta.importance) >= 5) ||
(
bestScore >= 0.22 &&
this.hasStrongSharedTopic(bestBefore, afterMeta) &&
/问题|假设|结论|原因|核心|方向|判断/.test(`bestBefore.line afterMeta.line`)
)
);
if (qualifies) {
usedBefore.add(bestIndex);
matchedPairs.push(this.buildChangedPair(bestBefore, afterMeta, bestScore));
} else {
additions.push(afterMeta);
}
}
const removals = beforeOnly.filter((_, index) => !usedBefore.has(index));
return { matchedPairs, additions, removals };
}
buildInvalidations(changedClaims, removals) {
const items = [];
for (const claim of changedClaims) {
const shouldInvalidate = claim.reasons.some((reason) => /数字|方向|blocker|风险|时间线/.test(reason))
|| claim.beforeMeta.staleCandidate
|| claim.afterMeta.blocker;
if (!shouldInvalidate) {
continue;
}
items.push({
stale: claim.before,
replacement: claim.after,
why: claim.reasons.join(','),
importance: claim.importance + 1
});
}
for (const removed of removals) {
if (!(removed.staleCandidate || removed.importance >= 5)) {
continue;
}
items.push({
stale: removed.line,
replacement: '最新快照里没有继续支撑这条说法,建议确认是否需要下线或改写',
why: '旧判断没有在新材料里继续得到支撑',
importance: removed.importance
});
}
return items
.sort((a, b) => b.importance - a.importance || a.stale.length - b.stale.length)
.slice(0, 5);
}
buildDecisionText(text) {
if (/法务|合规|审批|security|legal|安全审查/i.test(text)) {
return '确认是否立刻发起审批,并同步调整当前时间线';
}
if (/上线|发布|beta|deadline|排期|timeline|launch|release/i.test(text)) {
return '重新确认时间线、owner 和对外承诺';
}
if (/客户|用户|销售|模板|需求|customer|user|sales/i.test(text)) {
return '确认是否要调整本周优先级来响应这条新需求';
}
if (/blocker|风险|失效|冲突|tradeoff/i.test(text)) {
return '明确谁来拍板,以及拍板后哪条路径立即生效';
}
return '确认这条变化是否要改写当前计划、结论或资源分配';
}
buildDecisionsNeeded(changedClaims, importantAdditions) {
const items = [];
for (const claim of changedClaims) {
if (!(claim.importance >= 5 || claim.afterMeta.needsDecision || claim.afterMeta.blocker)) {
continue;
}
items.push({
topic: claim.topic,
conflict: `之前“this.shorten(claim.before, 42)”,现在变成“this.shorten(claim.after, 42)”`,
decision: this.buildDecisionText(`claim.before claim.after`),
importance: claim.importance + (claim.afterMeta.blocker ? 2 : 0)
});
}
for (const addition of importantAdditions) {
if (!(addition.needsDecision || addition.blocker || addition.importance >= 6)) {
continue;
}
items.push({
topic: this.shorten(addition.line, 24),
conflict: addition.line,
decision: this.buildDecisionText(addition.line),
importance: addition.importance
});
}
return items
.sort((a, b) => b.importance - a.importance || a.topic.length - b.topic.length)
.slice(0, 5);
}
buildActionFromAddition(addition) {
if (/法务|合规|审批|security|legal|安全审查/i.test(addition.line)) {
return `立即发起风险或审批处理:this.shorten(addition.line, 54)`;
}
if (/客户|用户|销售|模板|需求|customer|user|sales/i.test(addition.line)) {
return `把新增客户信号纳入本周优先级:this.shorten(addition.line, 54)`;
}
if (/上线|发布|beta|deadline|排期|timeline|launch|release/i.test(addition.line)) {
return `同步并更新时间线:this.shorten(addition.line, 54)`;
}
return `同步这条新增变化并指定 owner:this.shorten(addition.line, 54)`;
}
buildActionFromInvalidation(item) {
if (/法务|合规|审批|security|legal|安全审查/i.test(`item.stale item.replacement`)) {
return `撤下旧判断并切换到新的审批路径:this.shorten(item.replacement, 52)`;
}
if (/上线|发布|beta|deadline|排期|timeline|launch|release/i.test(`item.stale item.replacement`)) {
return `改写路线图和对外口径:this.shorten(item.replacement, 52)`;
}
return `更新仍在引用旧结论的文档或汇报:this.shorten(item.stale, 52)`;
}
buildTopActions(importantAdditions, invalidations, decisionsNeeded) {
const candidates = [];
for (const item of decisionsNeeded) {
candidates.push({
action: `item.decision:item.topic`,
why: item.conflict,
score: item.importance + 3
});
}
for (const item of invalidations) {
candidates.push({
action: this.buildActionFromInvalidation(item),
why: item.why,
score: item.importance + 2
});
}
for (const item of importantAdditions) {
candidates.push({
action: this.buildActionFromAddition(item),
why: item.line,
score: item.importance + item.actionScore
});
}
const deduped = [];
const seen = new Set();
for (const item of candidates.sort((a, b) => b.score - a.score || a.action.length - b.action.length)) {
if (seen.has(item.action)) {
continue;
}
seen.add(item.action);
deduped.push(item);
}
return deduped.slice(0, 3);
}
buildHeadline(analysis) {
const topAction = analysis.topActions[0]
? this.shorten(analysis.topActions[0].action, 34)
: '当前没有识别出高压变化';
return `识别出 analysis.importantAdditions.length 条高价值新增、analysis.changedClaims.length 处说法变化、analysis.invalidations.length 个旧结论风险。最值得先处理的是:topAction。`;
}
compare(input) {
const { before, after } = this.readPair(input);
const beforeLines = this.collectStatements(before);
const afterLines = this.collectStatements(after);
const beforeMeta = beforeLines.map((line) => this.classifyLine(line));
const afterMeta = afterLines.map((line) => this.classifyLine(line));
const beforeKeys = new Set(beforeMeta.map((item) => item.key));
const afterKeys = new Set(afterMeta.map((item) => item.key));
const beforeOnly = beforeMeta.filter((item) => !afterKeys.has(item.key));
const afterOnly = afterMeta.filter((item) => !beforeKeys.has(item.key));
const { matchedPairs, additions, removals } = this.matchChangedClaims(beforeOnly, afterOnly);
const importantAdditions = [...additions]
.sort((a, b) => b.importance - a.importance || b.actionScore - a.actionScore || a.line.length - b.line.length)
.filter((item, index) => item.importance >= 4 || index < 3)
.slice(0, 5);
const changedClaims = matchedPairs
.sort((a, b) => b.importance - a.importance || b.similarity - a.similarity)
.slice(0, 5);
const invalidations = this.buildInvalidations(changedClaims, removals);
const decisionsNeeded = this.buildDecisionsNeeded(changedClaims, importantAdditions);
const topActions = this.buildTopActions(importantAdditions, invalidations, decisionsNeeded);
const analysis = {
summary: this.buildHeadline({
importantAdditions,
changedClaims,
invalidations,
topActions
}),
counts: {
before: beforeLines.length,
after: afterLines.length,
additions: additions.length,
changedClaims: changedClaims.length,
invalidations: invalidations.length,
decisionsNeeded: decisionsNeeded.length
},
importantAdditions: importantAdditions.map((item) => ({
line: item.line,
importance: item.importance
})),
changedClaims: changedClaims.map((item) => ({
before: item.before,
after: item.after,
topic: item.topic,
reasons: item.reasons,
importance: item.importance
})),
invalidations,
decisionsNeeded,
topActions,
raw: {
before: beforeLines,
after: afterLines,
unmatchedBefore: removals.map((item) => item.line),
unmatchedAfter: additions.map((item) => item.line)
}
};
return analysis;
}
brief(input) {
const analysis = this.compare(input);
return {
mode: 'brief',
headline: analysis.summary,
importantAdditions: analysis.importantAdditions,
changedClaims: analysis.changedClaims,
invalidations: analysis.invalidations,
decisionsNeeded: analysis.decisionsNeeded,
topActions: analysis.topActions,
counts: analysis.counts
};
}
changes(input) {
const analysis = this.compare(input);
return {
mode: 'changes',
headline: analysis.summary,
items: analysis.importantAdditions,
counts: analysis.counts
};
}
invalidations(input) {
const analysis = this.compare(input);
return {
mode: 'invalidations',
headline: analysis.summary,
items: analysis.invalidations,
counts: analysis.counts
};
}
conflicts(input) {
const analysis = this.compare(input);
return {
mode: 'conflicts',
headline: analysis.summary,
items: analysis.decisionsNeeded,
counts: analysis.counts
};
}
priorities(input) {
const analysis = this.compare(input);
return {
mode: 'priorities',
headline: analysis.summary,
items: analysis.topActions,
counts: analysis.counts
};
}
analyze(input) {
return this.compare(input);
}
renderList(items, mapper, emptyText = '无') {
if (!items || items.length === 0) {
return `- emptyText`;
}
return items.map((item) => `- mapper(item)`).join('\n');
}
render(result) {
switch (result.mode) {
case 'brief':
return [
'变化一句话',
result.headline,
'',
'这周新增了哪些重要信息',
this.renderList(result.importantAdditions, (item) => `item.line(重要度 item.importance)`),
'',
'哪几处说法变了',
this.renderList(result.changedClaims, (item) => `之前“this.shorten(item.before, 32)” -> 现在“this.shorten(item.after, 32)”(item.reasons.join(','))`),
'',
'哪些旧结论可能失效',
this.renderList(result.invalidations, (item) => `旧结论“this.shorten(item.stale, 32)” -> 新现实“this.shorten(item.replacement, 32)”(item.why)`),
'',
'哪些冲突需要拍板',
this.renderList(result.decisionsNeeded, (item) => `item.topic:item.decision。证据:item.conflict`),
'',
'最值得立刻行动的 3 个变化',
this.renderList(result.topActions, (item) => `item.action(原因:this.shorten(item.why, 48))`)
].join('\n');
case 'changes':
return [
'重要新增变化',
result.headline,
'',
this.renderList(result.items, (item) => `item.line(重要度 item.importance)`)
].join('\n');
case 'invalidations':
return [
'可能失效的旧结论',
result.headline,
'',
this.renderList(result.items, (item) => `旧结论“this.shorten(item.stale, 36)” -> this.shorten(item.replacement, 44)(item.why)`)
].join('\n');
case 'conflicts':
return [
'需要拍板的变化冲突',
result.headline,
'',
this.renderList(result.items, (item) => `item.topic:item.decision。证据:item.conflict`)
].join('\n');
case 'priorities':
return [
'优先处理的变化',
result.headline,
'',
this.renderList(result.items, (item) => `item.action(原因:this.shorten(item.why, 48))`)
].join('\n');
default:
return JSON.stringify(result, null, 2);
}
}
}
module.exports = ChangeBrief;
FILE:test/test.js
const assert = require('assert');
const ChangeBrief = require('../src/index.js');
function run() {
const engine = new ChangeBrief();
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`PASS name`);
passed += 1;
} catch (error) {
console.log(`FAIL name: error.message`);
failed += 1;
}
}
const beforeText = `
上周假设 onboarding 最大问题是注册步骤太多,暂时不需要法务审批。
计划 4 月底上线企业版 beta。
客户反馈主要来自中小团队,平均部署周期 14 天。
目前默认只支持英文模板。
`;
const afterText = `
这周新增 6 条客户访谈,反复指出 onboarding 的核心问题是价值表达不清。
企业版 beta 改为 5 月中旬上线,需要先完成法务审批。
新签的两家大客户要求中文模板,本季度必须补上。
部署周期已降到 7 天,但安全审查成为新的 blocker。
`;
test('analyze finds important additions', () => {
const result = engine.analyze({ 'before-text': beforeText, 'after-text': afterText });
assert(result.importantAdditions.some((item) => item.line.includes('新签的两家大客户要求中文模板')));
});
test('analyze finds changed claims across snapshots', () => {
const result = engine.analyze({ 'before-text': beforeText, 'after-text': afterText });
assert(result.changedClaims.some((item) => item.before.includes('计划 4 月底上线企业版 beta') && item.after.includes('企业版 beta 改为 5 月中旬上线')));
});
test('invalidations catches stale legal assumption', () => {
const result = engine.invalidations({ 'before-text': beforeText, 'after-text': afterText });
assert(result.items.some((item) => item.stale.includes('暂时不需要法务审批') && item.replacement.includes('需要先完成法务审批')));
});
test('conflicts surfaces a decision-worthy change', () => {
const result = engine.conflicts({ 'before-text': beforeText, 'after-text': afterText });
assert(result.items.length >= 1);
assert(result.items.some((item) => /审批|时间线|优先级/.test(item.decision)));
});
test('priorities returns three or fewer ranked actions', () => {
const result = engine.priorities({ 'before-text': beforeText, 'after-text': afterText });
assert(result.items.length >= 1);
assert(result.items.length <= 3);
});
test('rendered brief includes the core management sections', () => {
const result = engine.brief({ 'before-text': beforeText, 'after-text': afterText });
const rendered = engine.render(result);
assert(rendered.includes('这周新增了哪些重要信息'));
assert(rendered.includes('哪些旧结论可能失效'));
assert(rendered.includes('最值得立刻行动的 3 个变化'));
});
console.log(`\npassed passed, failed failed`);
process.exit(failed > 0 ? 1 : 0);
}
run();
Household replenishment planning skill for mainland China that turns pantry status, weekly menu, repeat-purchase habits, and recent consumption clues into a...
---
name: PantryPilot
slug: pantrypilot
version: 1.0.0
description: Household replenishment planning skill for mainland China that turns pantry status, weekly menu, repeat-purchase habits, and recent consumption clues into a cross-platform home supply system across Meituan, PDD, Taobao, Meituan Maicai, Duoduo Maicai, and similar channels, estimates what is running low, routes urgent top-ups versus stock-up buys versus long-tail one-offs, and outputs the cheapest, fastest, and lowest-friction restock plans.
metadata:
clawdbot:
emoji: "🥫"
requires:
bins: []
os: ["linux", "darwin", "win32"]
---
# PantryPilot
One-line positioning:
Upgrade PDD, Meituan, and Taobao from one-off ordering tools into a household replenishment system.
PantryPilot is not another grocery or food-delivery skill.
It is the household supply operating layer above PDD, Meituan, Taobao, Meituan Maicai, Duoduo Maicai, Hema, Dingdong, and similar commerce channels.
Its job is to help the user answer:
- 家里哪些东西快用完了
- 这周菜单会把哪些食材和日用品推到补货线
- 哪些东西应该今晚在美团补
- 哪些东西更适合在拼多多囤
- 哪些单品应该去淘宝补齐
- 最低总价、最快送达、最省心三种补货方案分别是什么
- 哪些东西现在不该买,避免重复买、冲动买、凑错单
This skill should feel like a household quartermaster, not a shopping list formatter.
Read these references as needed:
- `references/replenishment-framework.md` for depletion estimation and restock cadence
- `references/platform-routing.md` for Meituan, PDD, Taobao, and grocery-channel routing logic
- `references/output-patterns.md` for the final answer structure
- `references/example-prompts.md` for demo prompts and trigger language
- `references/test-cases.md` for manual QA and acceptance checks
## Core Positioning
PantryPilot upgrades shopping advice from `这一单怎么买` to `这个家怎么持续补给`.
Default outcomes:
- estimate what is low now
- convert a menu or household routine into a replenishment list
- separate urgent top-ups from patient stock-up buys
- route each item to the right platform type
- compare cheapest total, fastest arrival, and lowest-friction restock plans
- stop unnecessary duplicates and fake savings before checkout
Do not stop at:
- a raw shopping list
- a one-platform recommendation
- a single cheapest basket with no household logic
- `都可以买,看你方便`
Always end with an operating plan the user can act on.
## Relationship To Other Skills
Keep the boundary clear:
- `MealScout` answers `这一刻该点哪家`
- `Maicai` answers `这篮菜今天去哪买更省`
- `Platform Price Hub` answers `同一商品跨平台该去哪里买`
- `CartPilot` answers `这一单怎么下最划算`
- `PriceTide` answers `现在买还是再等等`
- `PantryPilot` answers `这个家这一轮该补什么、分去哪几个平台、先补哪部分`
PantryPilot can absorb signals from those skills, but it should stay at the household-system layer:
- not just the best merchant
- but the best replenishment architecture
## When To Use It
Use this skill when the user says things like:
- `帮我看看这周家里该补什么`
- `按这周菜单帮我生成补货计划`
- `我把库存和最近买过的东西给你,帮我分成今晚补、周末囤、以后再买`
- `哪些适合走美团,哪些适合拼多多,哪些去淘宝补单品`
- `不要只给清单,给我最低总价 / 最快 / 最省心三种补货方案`
- `帮我避免重复买和冲动买`
- `把我家的补给系统运营起来`
It is strongest when the user provides:
- a pantry snapshot
- a weekly menu
- a repeat-purchase list
- recent order screenshots
- household size
- urgency such as `今晚做饭` or `周末统一囤货`
- budget or storage constraints
## What This Skill Must Do
Default to these jobs:
- estimate what is probably running low
- convert meals into missing ingredients and household supplies
- classify items by urgency, perishability, and replenishment cadence
- route items to immediate delivery, planned grocery, stock-up commerce, or long-tail marketplace paths
- compare total-cost, arrival-speed, and execution-friction tradeoffs
- explicitly mark what should not be bought yet
This skill is not just for food.
It should also work for household staples such as:
- paper goods
- cleaning supplies
- drinks
- condiments
- breakfast staples
- freezer or pantry stock-ups
- routine family consumables
If the user only gives partial context, infer carefully and label assumptions.
## Inputs
Useful inputs include:
- pantry notes or photos
- meal plan or weekly menu
- shopping list drafts
- recent order history screenshots
- household size and eating pattern
- platform screenshots
- target budget
- storage limits such as `冰箱快满了`
- timing constraints such as `今晚必须补齐` or `能等到周末`
If inputs are incomplete, prioritize:
- household size
- what is needed before the next meal window
- what is likely on a weekly cadence
- whether the user cares more about total savings, speed, or simplicity
If exact inventory is missing, estimate directionally rather than pretending certainty.
## Modes
1. weekly restock planning
- convert a household menu and pantry snapshot into this week's replenishment plan
2. depletion estimation
- infer what is likely running low from household size, routine, and recent orders
3. platform routing
- decide which items belong on Meituan, PDD, Taobao, or grocery channels
4. anti-duplicate cleanup
- identify items that should not be bought again yet
5. mixed urgency planning
- separate tonight-needed items from patient stock-up items
6. full household operating mode
- build a complete low-regret replenishment plan with primary and backup paths
## Household Supply Lens
Read `references/replenishment-framework.md` when cadence or depletion estimation matters.
Default item buckets:
- tonight gap
- three-day risk
- this-week staple
- monthly stock-up
- long-tail one-off
The goal is not perfect inventory accounting.
The goal is to prevent the common failures:
- dinner ingredients missing tonight
- household staples silently running out
- overbuying perishables
- rebuying items already sitting at home
- chasing platform thresholds with the wrong fillers
## Core Workflow
1. Identify the household mission.
- tonight rescue
- weekly restock
- weekend stock-up
- low-cash replenishment
- low-friction reset
2. Estimate depletion.
- what is confirmed low
- what is likely low
- what is only a nice-to-have
- what should be held because duplication risk is high
3. Convert demand into item groups.
- immediate meal ingredients
- short-cycle fresh items
- weekly staples
- bulky or shelf-stable stock-up items
- niche or branded one-offs
4. Route platforms by job.
- urgent same-day top-up
- planned grocery basket
- slow-but-cheap stock-up
- long-tail single-item补单
5. Simulate realistic plans.
- one-platform low-friction plan
- split-platform lowest-total plan
- fastest-arrival plan
- wait-and-bundle plan when the user can delay
6. Make the call.
- what to buy now
- what to delay
- where each group should go
- what not to buy
- what the user should do next
## Decision Rules
### Estimate Low Stock Conservatively
- If inventory is explicit, trust it.
- If inventory is unclear, infer from household size, last purchase timing, and menu pressure.
- Separate `confirmed low`, `likely low`, and `uncertain`.
- Do not present uncertain depletion as a hard fact.
Good phrasing:
- `这个是明确快见底。`
- `这个大概率该补,但我这里是按你们家两口人一周消耗做的推断。`
- `这个先别补,我更担心重复买。`
### Menu Pressure Beats Vague Wishlist
- A weekly menu is stronger than a generic wish list.
- If the menu will consume an ingredient within three to five days, bias toward action.
- If the item is not connected to an actual meal, routine, or shortage, downgrade it.
Good phrasing:
- `你这周会连做三顿早餐,鸡蛋和牛奶优先级要比零食高。`
- `这不是补货刚需,更像顺手想买。`
### Route By Urgency, Not Just By Price
Read `references/platform-routing.md` when channel choice matters.
- Urgent missing ingredients and same-day rescue items usually belong on Meituan or instant-retail paths.
- Planned fresh baskets can go to Meituan Maicai, Duoduo Maicai, Hema, Dingdong, or similar grocery channels.
- Shelf-stable household stock-ups often belong on PDD when the user can wait.
- Specific branded, niche, or hard-to-find one-offs often belong on Taobao or Tmall.
If a split route saves money but makes the household plan harder to execute, count that cost.
### Split Orders Only When The Household Wins
- Split-platform plans are good only when the gain is meaningful in total savings, urgency coverage, or waste reduction.
- Reject split routes when they save very little and force two extra errands, carts, or delivery windows.
- A mathematically cheaper plan is not better if it increases the chance the user never completes it.
Good phrasing:
- `最低价是分两单,但你这周不值得为了省 6 块把补货流程搞复杂。`
- `今晚缺的先走美团,囤货再走拼多多,这种拆法是有意义的。`
### Stop Duplicate Buys Before Talking About Deals
- First check whether the household already has enough.
- Penalize duplicated perishables more aggressively than duplicated dry goods.
- Treat filler items added only to hit a threshold as suspect unless they are already on the real replenishment list.
Good phrasing:
- `这不是补货,是重复买。`
- `这件现在先别下,不然你是在给库存加焦虑。`
- `如果不是本来就要补的纸巾,这个凑单不算省钱。`
### Stock-Up Is Not Free
- Count storage, spoilage risk, freezer space, and cash lock-up.
- Do not call oversized packs optimal just because unit price is lower.
- For small households, overbuying can be worse than paying slightly more.
Good phrasing:
- `单价更低,但对你们家一周消耗来说囤得太深了。`
- `这更像便宜的大包装,不是更好的补货方案。`
## Output Expectations
Read `references/output-patterns.md` for the default structure.
The answer should usually feel like:
- `先说结论,这一轮补货分两段做最稳。`
- `今晚缺口先补这些,囤货部分别混在一起。`
- `最低价能这样拆,但默认我更站更省心的路线。`
- `这几个先别买,不然大概率重复。`
If the user asks for a short answer, compress to:
- final replenishment call
- one-line routing logic
- one-line do-not-buy warning
## Browser-Oriented Use
When live validation is needed:
- inspect public product pages, grocery pages, activity pages, and user-provided screenshots
- normalize visible pack size, threshold, ETA, and fee structure
- validate whether an item is actually suitable for immediate buy, stock-up buy, or long-tail buy
- stop before login, payment, coupon claiming, or irreversible checkout actions
Capture when available:
- city
- household size
- item group
- visible pack size
- visible threshold rules
- ETA or delivery slot
- whether the item is fresh, shelf-stable, or niche
Do not invent real-time price, stock, or account benefits that the user has not provided.
## Safety Boundary
Allowed:
- estimate depletion from user-provided clues
- convert menu and pantry context into a replenishment plan
- compare public platform routes
- recommend what to buy now, later, or not at all
Do not:
- log in
- claim coupons
- read hidden account-only order history unless the user provides it
- place orders
- pretend to know exact household inventory when it is only inferred
FILE:CHANGELOG.md
# Changelog
## 1.0.0 - 2026-04-04
- Launch `PantryPilot / 补货参谋`
- Define PantryPilot as a household replenishment operating skill rather than a one-off shopping or grocery comparer
- Add cross-platform routing across Meituan, PDD, Taobao, and grocery channels
- Add replenishment logic for weekly menu mapping, depletion estimation, anti-duplicate filtering, and three-plan restock output
FILE:README.md
# PantryPilot
`PantryPilot`
中文名:`补货参谋`
一句话定位:
不是帮你选一单,而是帮你运营整个家里的补给系统。
`PantryPilot` 要做的不是“再做一个买菜 / 购物 skill”。
它解决的是更高频、更接近真实家庭生活的一层问题:
- 家里哪些东西快用完了
- 这周吃什么会把哪些食材和日用品推到补货线
- 哪些适合今晚走美团补
- 哪些适合在拼多多囤
- 哪些适合去淘宝补齐单品
- 最低总价、最快送达、最省心三种补货方案分别是什么
- 哪些东西现在先别买,避免重复买、冲动买、凑错单
## 它到底是什么
`PantryPilot` 是一个家庭补货系统 skill。
它不只处理一单,而是把家庭消费里的几个层次连起来:
- pantry / 冰箱 / 家用库存
- 一周菜单和生活节奏
- 周期性复购
- 不同平台的适配分工
- 最终下单路径
所以它回答的不是:
- “这次去哪家买更便宜”
而是:
- “这一轮我家该补什么”
- “哪些现在补,哪些这周补,哪些适合囤”
- “这些东西该分到哪个平台做”
## 核心输出
这个 skill 默认会把答案收敛成一套补货操作方案,而不是一张清单。
默认会给出:
- `What To Buy Now`
- `Platform Routing`
- `Lowest Total Price Plan`
- `Fastest Arrival Plan`
- `Lowest-Friction Plan`
- `Do-Not-Buy List`
- `Next Step`
也就是说,它既做“该补什么”,也做“怎么补最合理”。
## 为什么这个方向值钱
很多购物 skill 解决的是一次性决策:
- 这单值不值
- 这个商品哪里买
- 这个券该不该用
`PantryPilot` 解决的是周期性运营问题:
- 每周都要补
- 每月都要囤
- 每次都怕漏买、重复买、买错平台
它更容易形成留存,因为用户不是偶尔才会遇到这个问题,而是每周都会遇到。
## 它和相邻 skill 的边界
- `MealScout`:回答“这一刻该点哪家”
- `Maicai`:回答“这篮菜今天去哪买更省”
- `Platform Price Hub`:回答“同一商品跨平台该在哪里买”
- `CartPilot`:回答“这一单怎么下最划算”
- `PantryPilot`:回答“这个家这一轮该补什么、分去哪几个平台、先补哪部分”
也就是说:
别的 skill 更像一次交易的判断器。
`PantryPilot` 更像家庭补给系统的操作层。
## 适合回答的问题
- “帮我看看这周家里该补什么。”
- “按这周菜单帮我生成补货计划。”
- “我把库存和最近买过的东西给你,帮我分成今晚补、周末囤、以后再买。”
- “哪些适合走美团,哪些适合拼多多,哪些去淘宝补单品?”
- “不要只给清单,给我最低总价 / 最快 / 最省心三种补货方案。”
- “帮我避免重复买和冲动买。”
- “把我家的补给系统运营起来。”
## 它怎么帮用户
默认会做这几件事:
- 估算哪些东西明确快见底,哪些大概率该补
- 把菜单映射成食材和家庭日用品需求
- 区分今晚缺口、三天内风险、这周刚需、适合囤货
- 把商品分到 `Meituan / grocery channels / PDD / Taobao`
- 给出价格、时效、操作复杂度不同的三套补货路径
- 先拦住重复买和伪凑单
## 安全边界
| Action | Agent | User |
|------|-------|------|
| 读取公开商品页、用户提供的库存 / 菜单 / 订单截图 | yes | - |
| 估算补货需求、拆 urgency、分平台、给补货方案 | yes | - |
| 判断哪些先别买,避免重复买 | yes | - |
| 登录账号、读取隐藏权益、自动领券、自动下单 | no | yes |
| 支付或提交不可逆订单 | no | yes |
## Install
```bash
clawhub install pantrypilot
```
## 发布材料
```bash
sh ./scripts/publish.sh
```
## 一句话卖点
把 PDD、美团、淘宝从一次性下单工具升级成家庭补货系统:估算快用完的东西,把菜单映射成补货需求,并给出最低总价、最快送达、最省心三种补货路径。
FILE:RELEASE.md
# PantryPilot Release Notes
## Short Description
Turn PDD, Meituan, and Taobao into a household replenishment system: estimate what is running low, map weekly meals into restock demand, route items by platform, and output the cheapest, fastest, and lowest-friction restock plans.
## Marketplace Card Copy
Title:
- PantryPilot
Alternate title:
- 补货参谋
Short description:
- 把一次性下单升级成家庭补货系统,判断这轮该补什么、去哪补、哪些先别买
Install hook:
- 不是帮你选一单,而是帮你运营整个家的补给系统
## Announcement Copy
PantryPilot is not another shopping skill that only compares one basket.
It solves the more recurring household problem:
- what is running low at home
- what this week's menu will consume next
- what should be bought tonight versus this weekend
- what belongs on Meituan, PDD, or Taobao
- how the cheapest, fastest, and easiest restock plans differ
- what should not be bought yet to avoid duplication and fake savings
In one line:
It is not helping the user choose one order.
It is helping the user operate the whole household supply system.
## Suggested Tags
- latest
- shopping
- replenishment
- restock
- pantry
- household
- meal-planning
- grocery
- repeat-purchase
- meituan
- pdd
- taobao
## Suggested Repo Name
- `openclaw-skill-pantrypilot`
## Preflight
```bash
cd /absolute/path/to/pantrypilot
clawhub whoami
bash /absolute/path/to/codex/tmp/validate_clawhub_skill_dir.sh .
```
## Publish Command
### One command
```bash
cd /absolute/path/to/pantrypilot
sh scripts/publish.sh
```
### Manual command
```bash
clawhub publish /absolute/path/to/pantrypilot \
--slug pantrypilot \
--name "补货参谋" \
--version "1.0.0" \
--changelog "Launch 补货参谋 (PantryPilot), a household replenishment skill that estimates what is running low, maps weekly meals into restock demand, routes items across Meituan, PDD, and Taobao, and outputs the cheapest, fastest, and lowest-friction replenishment plans." \
--tags "latest,shopping,replenishment,restock,pantry,household,meal-planning,grocery,repeat-purchase,meituan,pdd,taobao"
```
FILE:agents/openai.yaml
interface:
display_name: "PantryPilot 补货参谋"
short_description: "把一次性下单升级成家庭补货系统,按平台路由每周复购"
default_prompt: "Use $pantrypilot to turn a household menu, pantry status, or repeat-purchase list into a replenishment plan across Meituan, PDD, and Taobao, then recommend the cheapest, fastest, and lowest-friction restock path in Chinese."
FILE:clawhub.json
{
"name": "pantrypilot",
"version": "1.0.0",
"description": "补货参谋 - 把 PDD、美团、淘宝从一次性下单工具升级成家庭补货系统,估算快用完的东西,把菜单映射成补货清单,并给出最低总价、最快送达、最省心三种补货方案",
"keywords": [
"pantrypilot",
"补货参谋",
"household-replenishment",
"pantry",
"restock",
"repeat-purchase",
"meal-plan",
"grocery",
"meituan",
"pdd",
"taobao",
"household-supply",
"shopping-decision"
],
"author": "openclaw",
"license": "MIT"
}
FILE:package.json
{
"name": "pantrypilot",
"version": "1.0.0",
"description": "PantryPilot - a household replenishment operating skill that turns one-off ordering into a home supply system",
"main": "SKILL.md",
"keywords": [
"pantrypilot",
"household-replenishment",
"restock",
"pantry",
"repeat-purchase",
"meal-planning",
"grocery",
"shopping",
"meituan",
"pdd",
"taobao",
"household-supply",
"openclaw-skill"
],
"author": "openclaw",
"license": "MIT"
}
FILE:references/example-prompts.md
# Example Prompts
Use this file for demos, smoke tests, marketplace copy, or manual evaluation.
## Weekly Household Restock
- 我家两口人,这周菜单有番茄炒蛋、咖喱鸡、清炒青菜、周末火锅,冰箱里鸡蛋只剩 4 个、牛奶快没了、纸巾也见底了,帮我做一轮补货计划
- 按我这周菜单和家里库存,帮我分成今晚要补、这周顺手补、适合囤货三类
- 不要只给购物清单,给我一个家庭补给方案
## Platform Routing
- 我把家里要买的东西列给你:鸡蛋、牛奶、青菜、洗衣液、抽纸、意面、橄榄油,帮我分成适合美团、拼多多、淘宝的路径
- 哪些东西今晚走美团更合理,哪些应该放到拼多多囤,哪些是淘宝补单品
- 帮我把这轮补货按平台拆开,但不要拆得太复杂
## Anti-Duplicate And Anti-Impulse
- 我怕自己又重复买,帮我看哪些这轮其实不该补
- 这些东西里哪些是真补货,哪些只是看着便宜想顺手买
- 我想凑个满减,但不想买错东西,帮我把不该凑的挑出来
## Three-Plan Output
- 按最低总价、最快送达、最省心三种方案给我这轮补货路径
- 这周我既想省钱又不想今晚断档,帮我给三个 restock plan
- 不要长篇分析,直接给我默认方案、最低价方案和最快方案
## Short-Answer Mode
- 直接告诉我这轮补货怎么拆
- 一句话说默认方案,再补一个最低价备选
- 只给我现在买什么、晚点买什么、先别买什么
FILE:references/output-patterns.md
# Output Patterns
## Final Restock Call
State the recommended replenishment architecture first.
## What To Buy Now
List the items that should be handled in the current urgency window.
## Platform Routing
State which bucket belongs on Meituan, grocery channels, PDD, Taobao, or similar paths.
## Three Plans
Show:
- `Lowest Total Price Plan`
- `Fastest Arrival Plan`
- `Lowest-Friction Plan`
If one route wins all three, say so directly.
## Do-Not-Buy List
Call out duplicates, impulse adds, and threshold fillers that should be removed.
## Next Step
Tell the user whether to place one order now, split now and later, or wait on part of the list.
FILE:references/platform-routing.md
# Platform Routing
Use this reference when deciding which replenishment bucket belongs on which platform type.
## Core Principle
Do not route by advertised price alone.
Route by:
- urgency
- perishability
- basket size
- packaging depth
- SKU specificity
- execution friction
## Meituan And Similar Instant-Retail Paths
Best for:
- tonight gap items
- missing dinner ingredients
- breakfast rescue items
- emergency household consumables
- small fresh top-ups
- convenience wins where execution speed matters more than absolute lowest price
Typical strengths:
- fast same-day fulfillment
- low decision latency
- strong for small urgent baskets
Typical weaknesses:
- higher unit price on bulky stock-up items
- packaging and delivery fees can erase small discounts
- bad default path for deep shelf-stable replenishment
Bias toward Meituan when:
- the item is needed before the next meal or morning routine
- the user wants one clean order with minimal planning
- paying a little more prevents a real continuity failure
## Meituan Maicai / Duoduo Maicai / Dingdong / Hema / Similar Grocery Channels
Best for:
- planned fresh baskets
- weekly vegetable and protein replenishment
- mixed produce + staples orders
- situations where freshness and basket-level value matter more than speed alone
Typical strengths:
- more natural whole-basket replenishment than instant takeout paths
- better for weekly fresh planning
- better fit for produce and recurring ingredients
Typical weaknesses:
- city and warehouse variance
- thresholds and delivery windows matter
- not every channel wins on every basket
Bias toward these channels when:
- the user is doing a true fresh restock
- there is enough basket depth to justify the threshold naturally
- the order does not need to land in the next hour
## PDD And Similar Stock-Up Commerce Paths
Best for:
- shelf-stable staples
- paper goods
- cleaning supplies
- drinks
- pantry stock-ups
- large packs where time flexibility exists
Typical strengths:
- deep-pack and family-pack pricing
- good for lowering refill frequency
- often strongest on routine household consumables when the user can wait
Typical weaknesses:
- slower fulfillment
- more friction if the item is actually needed now
- oversized packs may look optimal but be wrong for a small household
Bias toward PDD when:
- the item is low urgency
- the item is durable or shelf-stable
- the user wants to stock up rather than rescue tonight
## Taobao / Tmall And Similar Long-Tail Marketplace Paths
Best for:
- specific branded one-offs
- hard-to-find pantry or home items
- replacement tools or accessories
- niche condiments or specialty household goods
Typical strengths:
- SKU breadth
- long-tail coverage
- easier to find exact-match items
Typical weaknesses:
- not a great default path for emergency fresh replenishment
- not ideal for mixed urgent household baskets
- may add cognitive overhead if merged into a time-sensitive restock
Bias toward Taobao when:
- the exact item matters more than speed
- the item is irregular, branded, or niche
- the household is filling a known gap rather than doing this week's core fresh restock
## Split-Platform Logic
Recommend split routing only when the split solves a real household problem:
- Meituan for tonight's gap plus PDD for non-urgent stock-up
- grocery channel for fresh basket plus Taobao for one niche item
- local fast path for perishables plus slow commerce path for bulky goods
Reject split routing when:
- the savings are tiny
- the user must manage too many delivery windows
- the split is mathematically neat but operationally annoying
## Routing Shortcuts
Use these shortcuts when the user wants a fast answer:
- `今晚缺口`: Meituan / instant retail first
- `这周鲜食`: grocery channel first
- `耐放囤货`: PDD first
- `指定单品补齐`: Taobao or Tmall first
## Anti-Fake-Savings Reminders
Before routing, pressure-test the plan:
- Is this split saving real money after fees?
- Is the threshold natural or being forced?
- Is the deep pack actually appropriate for this household?
- Is a niche item delaying an otherwise simple restock?
FILE:references/replenishment-framework.md
# Replenishment Framework
Use this reference when you need to estimate what a household should replenish without perfect inventory data.
## Core Goal
Convert incomplete household signals into a practical replenishment decision:
- what is clearly low
- what is probably low
- what can wait
- what should not be bought again yet
## Confidence Labels
Always separate replenishment confidence:
- `confirmed`: the user explicitly says the item is low, nearly empty, or missing
- `likely`: inferred from household size, last order timing, or menu pressure
- `directional`: a planning guess with low confidence
Never present `likely` or `directional` items as exact household inventory facts.
## Default Depletion Buckets
### Tonight Gap
Use when the item is needed before the next meal or routine window.
Examples:
- cooking oil for tonight's dinner
- eggs for tomorrow's breakfast
- dish soap if the bottle is nearly empty
- milk for next-morning breakfast
Bias:
- prioritize action
- route toward same-day delivery or the nearest clean path
### Three-Day Risk
Use when the item is not missing now but is likely to fail within the next three days.
Examples:
- vegetables for the next two or three dinners
- fruit for kids' breakfast/snack routine
- bread, yogurt, tofu, or fresh meat
Bias:
- protect continuity
- avoid overbuying perishables
### This-Week Staple
Use when the item supports recurring meals or daily home function within the week.
Examples:
- eggs
- tomatoes
- potatoes
- onions
- rice for a high-consumption family
- tissues
- laundry detergent when clearly getting low
Bias:
- plan once
- do not force same-day premium if the user can wait a little
### Monthly Stock-Up
Use for bulky or shelf-stable consumables where deeper inventory is acceptable.
Examples:
- toilet paper
- kitchen paper
- trash bags
- bottled drinks
- rice
- noodles
- detergent refills
- canned or boxed pantry goods
Bias:
- optimize for value and low refill frequency
- penalize plans that overrun storage or cash comfort
### Long-Tail One-Off
Use for niche, branded, irregular, or hard-to-find items.
Examples:
- a specific imported condiment
- a replacement kitchen tool
- a preferred niche cereal
- a hard-to-find baby or health-related accessory
Bias:
- prioritize SKU match and friction control
- do not merge into grocery urgency unless it truly matters this week
## Signals For Estimating Depletion
### Strong Signals
- explicit remaining quantity
- pantry photo with visible stock
- recent order date plus known household size
- this week's menu consuming the item repeatedly
- user statement such as `快没了`, `见底了`, `只剩一点`
### Medium Signals
- household routine such as daily breakfast milk
- repeat-buy cadence such as weekly eggs
- recent order history that implies a typical cycle
- seasonality or school/work schedule changes
### Weak Signals
- generic desire to buy
- poster discounts with no household need
- impulsive stock-up interest without consumption context
## Menu-To-Restock Mapping
When the user gives a meal plan, convert it into demand pressure:
- repeated breakfast plan increases eggs, milk, bread, yogurt pressure
- soup or braise-heavy meals increase tomatoes, onions, potatoes pressure
- hotpot, barbecue, or guests increase short-term spike pressure
- lunchbox or office-week prep increases protein, vegetables, and convenience stock pressure
If the same ingredient appears in several planned meals, promote it ahead of generic pantry wants.
## Small-Household Versus Family Bias
### Small Household
For one or two people:
- penalize overbuying perishables
- prefer moderate packs
- do not call deep stock-up automatically optimal
### Larger Household
For three or more people:
- lean more toward stock continuity
- accept deeper pantry buffers
- treat missing staples as higher risk
## Duplicate-Buy Guardrails
Stop or downgrade items when:
- a recent purchase likely still covers the next cycle
- the user already has enough of the same functional item
- the new item is mainly a filler to hit a threshold
- perishability makes duplication risky
Good phrasing:
- `这更像重复买,不像补货`
- `如果上周刚买过这包规格,这轮先别补`
- `它可以当凑单品,但不该被当成补货刚需`
## Household Prioritization Order
When time or budget is tight, prioritize in this order:
1. tonight gap items
2. next three-day continuity items
3. this-week staples
4. monthly stock-up items
5. long-tail one-offs
6. impulse or discount-only adds
## Decision Shortcut
Use this shortcut when the user wants a fast answer:
- `必须现在补`
- `这周顺手补`
- `适合囤`
- `先别买`
FILE:references/test-cases.md
# Test Cases
Use these cases for manual QA, smoke tests, or acceptance checks.
## Case 1: Weekly Menu Plus Pantry Snapshot
Input shape:
- two-person household
- weekly menu with repeated breakfast and three dinners
- explicit low eggs and milk
- tissues nearly empty
Expected behavior:
- separate fresh meal pressure from household staple pressure
- route urgent breakfast gap items ahead of non-urgent stock-up
- avoid turning tissues into a same-day emergency if coverage still exists
## Case 2: Mixed Urgency Basket
Input shape:
- dinner ingredient missing tonight
- detergent and paper goods also getting low
- user wants one answer, not three disconnected carts
Expected behavior:
- recommend a clean split only if it materially helps
- put tonight's gap on fast local path
- push stock-up goods to a patient route when appropriate
## Case 3: Duplicate-Buy Risk
Input shape:
- recent purchase screenshot shows a large rice bag last week
- user adds rice again because of a promotion
Expected behavior:
- flag rice as likely duplicate buy
- downgrade promo appeal
- keep focus on real low-stock items
## Case 4: Small Household Over-Stocking
Input shape:
- one-person household
- large PDD family packs look cheapest
- no strong storage capacity
Expected behavior:
- penalize oversized packs
- explain why unit price is not the only metric
- prefer moderate replenishment depth
## Case 5: Three-Plan Output
Input shape:
- user explicitly asks for lowest total, fastest, and lowest-friction plans
- basket includes urgent fresh items plus non-urgent pantry goods
Expected behavior:
- provide all three plans
- keep each plan executable
- make one default call instead of leaving the user with a tie
FILE:scripts/publish.sh
#!/usr/bin/env sh
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
WORKSPACE_ROOT="$(CDPATH= cd -- "$ROOT/../.." && pwd)"
CLAWHUB_JSON="$ROOT/clawhub.json"
VALIDATOR="$WORKSPACE_ROOT/tmp/validate_clawhub_skill_dir.sh"
VERSION="$(node -e "process.stdout.write(require(process.argv[1]).version)" "$CLAWHUB_JSON")"
SLUG="pantrypilot"
NAME="补货参谋"
CHANGELOG="Launch 补货参谋 (PantryPilot), a household replenishment skill that estimates what is running low, maps weekly meals into restock demand, routes items across Meituan, PDD, and Taobao, and outputs the cheapest, fastest, and lowest-friction replenishment plans."
TAGS="latest,shopping,replenishment,restock,pantry,household,meal-planning,grocery,repeat-purchase,meituan,pdd,taobao"
PUBLISH_ROOT="$ROOT"
TMP_DIR=""
if ! command -v clawhub >/dev/null 2>&1; then
echo "clawhub CLI not found in PATH" >&2
exit 1
fi
if ! command -v node >/dev/null 2>&1; then
echo "node is required to read version metadata from clawhub.json" >&2
exit 1
fi
if [ -d "$ROOT/.git" ]; then
TMP_DIR="$(mktemp -d)"
PUBLISH_ROOT="$TMP_DIR/$SLUG"
mkdir -p "$PUBLISH_ROOT"
cp "$ROOT/SKILL.md" "$PUBLISH_ROOT/SKILL.md"
cp "$ROOT/README.md" "$PUBLISH_ROOT/README.md"
cp "$ROOT/RELEASE.md" "$PUBLISH_ROOT/RELEASE.md"
cp "$ROOT/CHANGELOG.md" "$PUBLISH_ROOT/CHANGELOG.md"
cp "$ROOT/package.json" "$PUBLISH_ROOT/package.json"
cp "$ROOT/clawhub.json" "$PUBLISH_ROOT/clawhub.json"
cp -R "$ROOT/agents" "$PUBLISH_ROOT/agents"
cp -R "$ROOT/references" "$PUBLISH_ROOT/references"
cp -R "$ROOT/scripts" "$PUBLISH_ROOT/scripts"
trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
fi
if [ "-" = "--print" ]; then
cat <<EOF
clawhub publish "$PUBLISH_ROOT" \\
--slug "$SLUG" \\
--name "$NAME" \\
--version "$VERSION" \\
--changelog "$CHANGELOG" \\
--tags "$TAGS"
EOF
exit 0
fi
if [ -f "$VALIDATOR" ]; then
bash "$VALIDATOR" "$PUBLISH_ROOT"
fi
echo "Publishing PantryPilot from: $PUBLISH_ROOT"
echo "Version: $VERSION"
echo "Name: $NAME"
echo "Tags: $TAGS"
clawhub publish "$PUBLISH_ROOT" \
--slug "$SLUG" \
--name "$NAME" \
--version "$VERSION" \
--changelog "$CHANGELOG" \
--tags "$TAGS"
Post-purchase order and after-sales management skill for mainland China shopping and delivery scenarios that records order state, arrival, return or exchange...
---
name: OrderKeeper
slug: orderkeeper
version: 1.0.0
description: Post-purchase order and after-sales management skill for mainland China shopping and delivery scenarios that records order state, arrival, return or exchange windows, price-protection windows, warranty period, and issue evidence, then decides whether the user should request a refund, replacement, reshipment, compensation, warranty service, or wait; generates customer-service scripts; and outputs an after-sales card linking receipt, warranty, and outcome. Use when the user says things like "买完之后怎么不吃亏", "这单再不处理就超时了", "现在该退款还是先观望", "帮我写客服话术", "价保快过期了", or "把订单、收据、保修串起来".
metadata:
clawdbot:
emoji: "📦"
requires:
bins: []
os: ["linux", "darwin", "win32"]
---
# OrderKeeper
One-line positioning:
Do not help the user buy. Help the user not lose after buying.
Signature line:
`从签收到维权,全程不掉链子的购物后援。`
OrderKeeper is not another price-comparison skill.
It is not a parcel tracker.
It is not a passive receipt archive.
It is the post-purchase operations layer after the order already exists.
Its job is to help the user answer:
- 这个订单现在最该处理的动作是什么
- 该退款、换货、补发、赔偿,还是先观望
- 哪个时限最危险,什么再不处理就超时
- 现在应该保存哪些证据
- 客服第一句话该怎么说
- 收据、保修、订单结果怎么串成一张售后卡
The tone should feel like a calm after-sales operator who has seen too many orders go bad because nobody moved in time:
- deadline-aware
- evidence-first
- action-oriented
- willing to say `现在就处理`
- willing to say `先别急着升级动作`
## Product Boundary
Think of the shopping stack like this:
- `Worth Buying`: is it worth the money
- `Buying`: where to buy it
- `CartPilot`: how to place the order
- `ShopGuard`: whether this route is safe enough to take
- `OrderKeeper`: now that the order exists, how to keep the user from losing money, time, or leverage
Keep the boundary clear:
- if the user is still comparing products, sellers, or platforms, use the buying skills first
- if the user is already holding an order, receipt, package, issue, or deadline, use `OrderKeeper`
- if the user mainly wants long-term asset cataloging, storage location, warranty archiving, or receipt export, hand off to inventory or receipt-style tools
- if the user wants the active order lifecycle, issue triage, deadline pressure, and customer-service action, keep it inside `OrderKeeper`
OrderKeeper does not answer `should I buy`.
It answers `now that I bought, what keeps me from getting stuck`.
## When To Use It
Use this skill when the user says things like:
- `买完之后怎么不吃亏`
- `这单再不处理就超时了吧`
- `现在该退款还是先观望`
- `缺件了,怎么跟客服说`
- `发错货了,是换货还是直接退`
- `这个价保是不是快过了`
- `商品有问题,但我还想要这个东西,怎么处理最省事`
- `把订单、收据、保修和处理结果串成一张售后卡`
It is strongest when the user already has:
- an order screenshot
- delivery or sign-off time
- issue photos or chat history
- receipt or invoice
- warranty information
- price-drop evidence
- a deadline the user may miss
## What This Skill Must Do
By default, it should:
- turn one messy order problem into a clean timeline
- identify the nearest risky clock: return, exchange, price protection, warranty, or complaint delay
- classify the issue: missing item, wrong item, damage, delay, quality problem, price drop, invoice issue, warranty issue
- decide whether the next move should be refund, replacement, reshipment, compensation, warranty claim, or short observation
- tell the user what evidence to save before they lose leverage
- generate a concise, usable customer-service script
- link receipt, warranty, and final outcome into one after-sales card
Do not stop at:
- repeating the order status
- generic consumer-rights talk
- a bland checklist with no recommendation
- `可以联系客服看看`
Always convert the situation into an action.
## Core Modes
1. order intake mode
- build the order timeline, key dates, and current risk clock
2. arrival issue triage mode
- missing item, wrong item, damage, spoilage, or obvious quality problem after delivery
3. refund / exchange / compensation mode
- decide the smallest move that fully protects the user
4. price-protection mode
- judge whether to claim price protection, ask for refund-and-rebuy, or ignore the drop
5. warranty mode
- connect symptom, receipt, warranty term, and service route
6. support-script mode
- generate the exact message the user should send next
7. after-sales card mode
- compress the whole case into one card the user can reopen later
Read [references/deadline-triage.md](references/deadline-triage.md) when the hard part is deciding between refund, replacement, reshipment, compensation, or wait.
Read [references/support-script-frames.md](references/support-script-frames.md) when the user mainly needs customer-service wording.
Read [references/after-sales-cards.md](references/after-sales-cards.md) when the user wants a cleaner order card or timeline.
## Inputs
Useful inputs include:
- order screenshots
- delivery status
- sign-off or arrival time
- product photos, unboxing photos, packaging photos
- chat records
- refund or after-sales policy screenshots
- receipt, invoice, warranty card, or serial number
- price-drop screenshots
- the user's actual goal, such as `I still want the item`, `I need money back fast`, or `I just want this fixed with minimum hassle`
If information is incomplete, prioritize inferring or clarifying:
- what actually happened
- what deadline is closest
- whether the user still wants the product
- whether the issue is partial, total, or still uncertain
If a time window is not confirmed, label it as unknown instead of inventing it.
## Core Workflow
1. Build the timeline.
Capture:
- order placed
- shipped
- delivered or signed
- issue discovered
- price dropped
- warranty end or relevant after-sales window
2. Classify the issue.
Decide whether the case is mainly:
- missing item
- wrong item
- damage
- quality problem
- delay
- price protection
- invoice or receipt issue
- warranty claim
3. Identify the nearest dangerous clock.
Ask:
- what expires first
- what evidence may disappear first
- whether waiting helps or only burns leverage
4. Choose the action.
Decide between:
- refund
- exchange
- reshipment
- compensation
- warranty claim
- short observation window
5. Prepare the support move.
Give:
- evidence checklist
- concise script
- escalation direction if the first reply stalls
6. Write the after-sales card.
Link:
- order fact pattern
- deadline
- evidence
- current action
- desired outcome
- final result when known
## Decision Rules
### The Nearest Clock Matters More Than The Loudest Emotion
The user may be angry, but the real question is what can still be preserved.
Preferred phrasing:
- `先处理时限,再处理情绪。`
- `这单最危险的不是问题本身,而是快超时。`
- `现在不先把动作发出去,后面会少一层杠杆。`
### Evidence Before Extra Use
Before the user keeps using, washing, assembling, discarding packaging, or letting food fully disappear, tell them what to save first.
Good reminders:
- `先留图,再决定怎么谈。`
- `先把外箱、标签、缺件位和聊天记录固定下来。`
- `先保留价格截图和下单信息,不然价保会变弱。`
### Refund, Replacement, Reshipment, Compensation, Or Wait
Use this bias:
- refund when the core product is wrong, trust is broken, or the user no longer wants the route
- replacement or reshipment when the user still wants the item and the problem is fixable
- compensation when the item is still usable and the user mainly wants fairness, not reversal
- short observation only when no key clock is about to expire and the issue may genuinely self-resolve
Do not recommend waiting when the only thing it does is burn the window.
### Price Protection Is A Clock, Not Just A Feeling
If the user sees a price drop:
- decide whether the route supports price protection
- compare claim effort against savings
- if price protection is unclear, say so and recommend the fastest evidence-preserving move
Good wording:
- `这更像价保动作,不是情绪动作。`
- `如果今天不留价差证据,这笔差价明天可能就谈不动了。`
### Warranty Needs Proof, Not Just Memory
When warranty is the path:
- connect symptom
- receipt or invoice
- serial or product identity
- service route
- time remaining
If one of these is missing, say which missing piece weakens the claim.
## Output Pattern
Use this structure unless the user wants something shorter:
### After-Sales Verdict
Give the direct action first.
### Clock And Window
State the deadline or the most dangerous missing date.
### What Happened
Summarize the order fact pattern in one short block.
### Evidence To Save Now
List the proof the user should preserve immediately.
### Recommended Move
Say whether to refund, exchange, reship, compensate, warranty-claim, or observe briefly.
### Customer Service Script
Write the next message in concise, sendable Chinese.
### After-Sales Card
Tie together:
- order
- receipt or invoice
- warranty
- current status
- desired outcome
- next checkpoint
## Finish Standard
When this skill is done well, the user should know:
- what the problem is in one sentence
- what clock matters most
- what to do right now
- what evidence not to lose
- what exact words to send
- how the order, receipt, warranty, and outcome fit together
FILE:CHANGELOG.md
# Changelog
## 1.0.0
Release theme: 从“买前判断”延伸到“买后不吃亏”。
What changed:
- 发布 `OrderKeeper / 收货总管`,明确定位为购物后链路和售后管理 skill
- 支持把订单问题拆成 timeline、deadline、evidence、action 四部分
- 强化缺件、错单、损坏、延迟、价保、保修等问题的动作判断
- 默认输出客服沟通话术、证据清单和售后卡,而不是泛泛建议
- 把订单、收据、保修和处理结果串成一个可复用的 after-sales card
Suggested one-line changelog:
- Launched OrderKeeper, a post-purchase order and after-sales skill that flags deadlines, chooses the right remedy, generates support scripts, and ties order, receipt, warranty, and outcome into one card.
FILE:README.md
# OrderKeeper
不是帮你买,而是帮你把“买完之后的麻烦事”接住。
`OrderKeeper / 收货总管` 不是购物前的判断 skill,而是购物后的后援 skill。它要解决的是这类更现实的问题:
- 这个订单现在最该处理的动作是什么
- 该退款、换货、补发、赔偿,还是先观望
- 哪个时限最危险,再不处理就要超时
- 现在应该保存哪些证据
- 客服第一句话该怎么说
- 收据、保修、订单结果怎么串成一张售后卡
一句话说,它回答的不是“值不值得买”,而是:
`买完怎么不吃亏。`
## 它适合什么问题
- “这单再不处理是不是就超时了?”
- “现在该退款还是先观望?”
- “缺件了,怎么跟客服说?”
- “发错货了,是换货还是直接退?”
- “价保窗口是不是快过了?”
- “保修期还在,这个问题怎么走最省事?”
- “把订单、收据、保修和处理结果串成一张售后卡。”
## 最适合的输入
- 订单截图
- 签收或到货时间
- 开箱照片、问题照片、聊天记录
- 收据、发票、保修卡
- 降价截图
- 退换货或价保规则
## 默认会输出什么
它默认不是长文解释,而是一张售后卡:
- `After-Sales Verdict`
- `Clock And Window`
- `What Happened`
- `Evidence To Save Now`
- `Recommended Move`
- `Customer Service Script`
- `Order / Receipt / Warranty Link`
- `Next Checkpoint`
也就是说,它的价值不是“再讲道理”,而是把时限、动作、证据和话术一次收住。
## 和相邻 skill 的边界
- `Worth Buying`:这东西值不值得买
- `Buying`:应该在哪里买
- `CartPilot`:这一单怎么下最优
- `ShopGuard`:这条购买路径安不安全
- `OrderKeeper`:订单已经有了,接下来怎么不吃亏
如果用户还在比价、比店、比平台,用前面的 skill。
如果用户已经在面对订单、签收、退换货、价保、保修、客服沟通,那就是 `OrderKeeper` 的主场。
## 为什么这条产品线值得做
你的购物线前链路已经很强了,但买完之后通常才是真正开始消耗用户时间的地方:
- 缺件、错单、延迟、质量问题
- 价保快过期
- 保修和发票找不到
- 客服该怎么说最省事
- 哪一步再不做就会失去杠杆
`OrderKeeper` 做的就是把这条后链路接住:不是继续判断“要不要买”,而是把“买完之后怎么不掉链子”系统化。
## 典型提示词
- `用收货总管帮我看这单现在该退款还是换货`
- `这单价保是不是快过了,应该怎么说`
- `缺件了,帮我写一段客服话术`
- `把这笔订单整理成售后卡,告诉我下一步`
- `这个问题还在保修期内吗,怎么走最省事`
## 安装
```bash
clawhub install orderkeeper
```
## 适合放在列表页的短介绍
购物后链路管家,帮你盯订单时限、售后动作、价保窗口、保修路径和客服沟通话术。
## 一句话卖点
从签收到维权,全程不掉链子的购物后援。
FILE:RELEASE.md
# OrderKeeper Release Notes
## Short Description
购物后链路管家,帮你盯订单时限、售后动作、价保窗口、保修路径和客服沟通话术。
## Marketplace Card Copy
Title:
- 收货总管
Alternate title:
- OrderKeeper
Short description:
- 购物后链路管家,帮你盯订单时限、售后动作、价保窗口、保修路径和客服沟通话术
Install hook:
- 不是帮你买,而是帮你把买完之后的麻烦事接住
## Announcement Copy
`收货总管(OrderKeeper)` 不是购物前的判断 skill。
它解决的是更靠后的、也更容易让人掉链子的那一段:
- 这单现在该退款、换货、补发、赔偿,还是先观望
- 哪个时限最危险,再拖就超时
- 现在要留哪些证据
- 客服第一句话应该怎么说
- 收据、保修、订单结果怎么串起来
所以它的定位很明确:
`不是帮你买,而是帮你把“买完之后的麻烦事”接住。`
默认它会给出:
- After-Sales Verdict
- Clock And Window
- Evidence To Save Now
- Recommended Move
- Customer Service Script
- After-Sales Card
也就是说,它不是在复述订单状态,而是在把售后动作收成一个可执行结论。
## Official Launch Post
今天做了一个我很喜欢的新 OpenClaw skill:`收货总管(OrderKeeper)`。
很多购物 skill 都在解决买前问题:
- 值不值得买
- 应该在哪里买
- 这一单怎么下最优
- 这个 seller 风险高不高
但用户真正开始花时间的,往往是买完之后:
- 缺件了
- 发错货了
- 到货延迟
- 商品有质量问题
- 价保快过期
- 发票和保修链路不清楚
- 客服该怎么说最省事
所以 `收货总管` 做的不是买前判断,而是买后接盘。
它会直接把问题压成:
- 现在最该处理的动作
- 最危险的时限
- 必须保留的证据
- 可直接发送的客服话术
- 订单 / 收据 / 保修 / 结果的一张售后卡
我给它定的一句话文案是:
`从签收到维权,全程不掉链子的购物后援。`
如果你也经常遇到“不知道现在该退、该换、该等,还是该去谈价保”的情况,这个 skill 会很顺手。
## Suggested Tags
- latest
- shopping
- post-purchase
- after-sales
- refund
- exchange
- reshipment
- price-protection
- warranty
- receipt
- customer-service
## Suggested Repo Name
- `openclaw-skill-orderkeeper`
## Manual Publish Command
```bash
clawhub publish /absolute/path/to/orderkeeper \
--slug orderkeeper \
--name "收货总管" \
--version "1.0.0" \
--changelog "Launch 收货总管 (OrderKeeper), a post-purchase order and after-sales skill that flags deadlines, chooses whether to refund, exchange, reship, compensate, or wait, generates customer-service scripts, and ties order, receipt, warranty, and outcome into one card." \
--tags "latest,shopping,post-purchase,after-sales,refund,exchange,reshipment,price-protection,warranty,receipt,customer-service"
```
FILE:agents/openai.yaml
interface:
display_name: "收货总管"
short_description: "购物后链路管家,盯订单时限、售后动作、价保提醒与客服话术"
default_prompt: "Use $orderkeeper to manage my post-purchase order, tell me whether I should request a refund, exchange, reshipment, compensation, price protection, warranty service, or wait, flag any return or warranty deadlines, generate a customer-service script, and answer in Chinese."
FILE:clawhub.json
{
"name": "orderkeeper",
"version": "1.0.0",
"description": "收货总管 - 购物后链路管家,管理订单、签收、退换货、价保、保修和客服话术,帮用户在买完之后不吃亏。",
"keywords": [
"orderkeeper",
"post-purchase",
"after-sales",
"refund",
"exchange",
"reshipment",
"compensation",
"price-protection",
"warranty",
"customer-service-script",
"receipt",
"invoice",
"order-management",
"shopping",
"delivery"
],
"author": "openclaw",
"license": "MIT"
}
FILE:references/after-sales-cards.md
# After-Sales Cards
Use these frames when the user wants the order case compressed into one reusable card.
## Standard After-Sales Card
```md
### After-Sales Verdict
### Clock And Window
### What Happened
### Evidence To Save Now
### Recommended Move
### Customer Service Script
### Order / Receipt / Warranty Link
### Next Checkpoint
```
## Deadline-Heavy Card
Best when the user mainly needs urgency handling.
```md
### Immediate Action
### Most Dangerous Clock
### What Must Be Preserved First
### Message To Send Now
### If They Stall
```
## Warranty Bundle Card
Best when the issue is no longer a fresh-delivery problem but a service problem.
```md
### Current Problem
### Warranty Status
### Receipt / Invoice Status
### Evidence Available
### Service Route
### What To Ask For
### Next Deadline
```
## Tone Rules
- Start with the move, not the story.
- Make the next checkpoint explicit.
- Keep the script short enough to send without rewriting.
- Prefer `现在先做这个` over generic advice.
FILE:references/deadline-triage.md
# Deadline Triage
Use this guide when the problem is not `what happened`, but `what should happen next before the window closes`.
## Clock Rules
1. Prefer explicit platform or seller windows over generic assumptions.
2. If the user cannot confirm the exact window, mark it as unknown.
3. The nearest deadline should usually shape the recommendation.
4. Evidence can disappear before the formal window closes. Treat that as a real clock too.
5. Do not recommend waiting unless waiting preserves leverage or meaningfully improves certainty.
Useful phrasing:
- `这单先看时限,不先看道理。`
- `正式窗口可能还在,但证据窗口已经在变弱。`
- `现在最危险的是拖到系统默认确认 / 价保过窗 / 保修到期。`
## Issue Type To Action Bias
### Missing Item
Bias toward:
- reshipment if the user still wants the complete order
- partial refund if only one component is missing and the user accepts that route
- full refund if the route already feels unreliable or the order value is low enough to stop
Evidence to save:
- outer box
- inner package
- weight or packing evidence if available
- missing slot or missing accessory photo
- unboxing timeline if available
### Wrong Item
Bias toward:
- replacement or exchange if the user still wants the correct item
- refund if trust is already broken or the seller is noisy
Evidence to save:
- wrong SKU or wrong color photo
- label and barcode
- order details beside the delivered item
### Damage Or Obvious Defect
Bias toward:
- replacement if the user still wants the item and damage is clear
- refund if the damage is severe, trust is low, or repeat handling would be annoying
- compensation only if the user can accept the item as-is
### Delay
Bias toward:
- short observation when the user still needs the order and the route may recover quickly
- compensation request if service promise clearly slipped
- cancel or refund when delay destroys the use case
### Price Drop
Bias toward:
- price-protection request if the route seems eligible
- refund-and-rebuy only when the savings are meaningful and the operational friction is acceptable
- ignore when the savings are tiny and the case would become its own project
Evidence to save:
- old paid price
- new visible price
- same SKU or same listing proof
- time of screenshot
### Warranty Claim
Bias toward:
- warranty route when the issue clearly belongs to covered use and the window is still open
- paid repair or alternative route only if warranty proof is weak or expired
Evidence to save:
- invoice or receipt
- serial or product identity
- symptom video or photo
- service history if any
## Wait Versus Act
Prefer `act now` when:
- the issue is already visible
- the next delay only reduces leverage
- a return, exchange, or price-protection window is near
- chat acknowledgment from customer service itself helps preserve position
Prefer `wait briefly` only when:
- the issue may self-resolve soon
- there is still a comfortable time buffer
- the user has already preserved evidence
## Action Priority
When several things are true at once, prioritize in this order:
1. preserve evidence
2. send the first deadline-preserving message
3. choose the remedy path
4. tidy the card, receipt, and warranty record
FILE:references/support-script-frames.md
# Support Script Frames
Use these frames when the user wants a sendable customer-service message, not an essay.
## Script Rules
- Keep the message short and specific.
- Ask for one clear remedy.
- Mention the evidence you already have.
- Mention the deadline only if it increases urgency.
- Do not over-threaten in the first message.
## Missing Item
```text
你好,这笔订单我已经收到了,但现在核对发现少了一件 / 少了一个配件。我这边已经保留了外箱、包装和缺件照片。请尽快帮我安排补发,或者给我对应缺件的退款处理方案。谢谢。
```
## Wrong Item
```text
你好,这单收到的商品和我下单的规格 / 颜色 / 型号不一致。我已经保留了商品照片、标签和订单截图。请帮我尽快换成正确商品;如果不能尽快处理,请直接走退款方案。谢谢。
```
## Damage Or Defect
```text
你好,这单商品到手后发现有明显破损 / 故障 / 质量问题。我这边已经保留了开箱和问题照片 / 视频。请告知我现在优先走换货、退款,还是你们可以先给出补偿处理方案。
```
## Delay
```text
你好,这笔订单当前已经明显晚于原先承诺时效,影响了我的使用安排。请先帮我确认最晚何时送达;如果无法在这个时间前完成,请给我取消 / 补偿的处理方案。谢谢。
```
## Price Protection
```text
你好,我这笔订单下单后同款 / 同链接价格出现了明显下降。我已经保留了下单价格和当前价格截图。请帮我确认这单是否可以申请价保或差价补偿。谢谢。
```
## Invoice Or Warranty
```text
你好,这笔订单我需要补开 / 确认发票,并同步确认保修对应的售后路径。我这边可以提供订单号、商品信息和收货信息。请帮我把发票 / 保修处理方式一次说明清楚。谢谢。
```
## Escalation Upgrade
Use only after the first round stalls:
```text
你好,我前面已经提交过问题说明和证据,目前还没有拿到明确处理结论。因为这个订单涉及退换 / 价保 / 保修时限,请你们直接给出可执行方案和预计完成时间。若还需要补充材料,也请一次说明完整。
```
Evidence-first knowledge auditing skill that upgrades connected knowledge into an auditable conclusion card. It traces which sources support a conclusion, ma...
---
name: ContextLedger
slug: contextledger
version: 1.0.0
description: Evidence-first knowledge auditing skill that upgrades connected knowledge into an auditable conclusion card. It traces which sources support a conclusion, marks which cited source is oldest or stale, surfaces source conflicts, separates direct evidence from inference, and ends with the most reliable next judgment. Use when the user says things like "这个结论从哪来的", "哪份资料已经旧了", "这些资料互相冲突怎么办", "不要长摘要,给我证据卡", or "哪些地方只是推断不是证据".
metadata:
clawdbot:
emoji: "🧾"
requires:
bins: []
os: ["linux", "darwin", "win32"]
---
# ContextLedger
One-line positioning:
Give knowledge an audit trail: source traceability, freshness judgment, conflict flags, and a reliable next call.
ContextLedger is not another note app.
It is not a passive knowledge graph.
It is not a long-form summarizer that smooths disagreement away.
It is the audit layer that sits after information has already been gathered.
Its job is to help the user answer:
- 这个结论到底来自哪几份资料
- 哪一份最旧,哪一份可能已经过时
- 哪两份资料在互相打架
- 哪些句子是直接证据,哪些只是推断
- 在不确定还存在的情况下,现在最可靠的判断是什么
The tone should feel like a careful knowledge auditor:
- evidence first
- dates matter
- disagreement stays visible
- inference must be labeled
- the final judgment should be useful, not evasive
## Product Boundary
Think of the knowledge stack like this:
- `Knowledge Connector`: connect, import, search, and relate knowledge
- `ContextLedger`: audit where the conclusion comes from and how trustworthy it is
- `DecisionDeck`: compress the audited material into a decision brief
- `NextFromKnowledge`: turn the audited material into the next move
Keep the boundary clear:
- if the user needs ingestion, retrieval, or relationship discovery, use `Knowledge Connector` first
- if the user needs source traceability, freshness, contradiction handling, or evidence grading, use `ContextLedger`
- if the user needs a boss-ready decision brief, hand the audited result to `DecisionDeck`
- if the user needs action, hand the audited result to `NextFromKnowledge`
ContextLedger does not win by knowing more.
It wins by making knowledge inspectable.
## When To Use It
Use this skill when the user says things like:
- `这个说法是从哪来的`
- `哪份资料已经旧了`
- `这些文件在互相矛盾`
- `不要长摘要,给我证据账本`
- `哪些地方是事实,哪些只是推断`
- `我想知道现在最可靠的判断,不要装得很确定`
- `把这几份文档的依据、冲突和更新风险说清楚`
- `资料来源混杂,帮我做可信度梳理`
It is strongest when the user has:
- notes, docs, reports, or meeting summaries
- connector outputs or copied web research
- local knowledge mixed with external sources
- a conclusion that now needs provenance and trust checks
- time-sensitive material where recency can change the answer
It is especially useful when the user already suspects:
- the sources are old
- several documents disagree
- some claims are second-hand
- the previous summary hid uncertainty
## What This Skill Must Do
By default, it should:
- identify the exact claim, conclusion, or question being audited
- attach the most relevant 2 to 5 sources behind that claim
- mark which cited source is newest, oldest, undated, or likely stale
- distinguish direct evidence, corroborated evidence, inference, assumption, and unknown
- surface conflicts without collapsing them into fake consensus
- explain whether the conflict changes the current judgment
- end with the most reliable next judgment the evidence can support right now
Do not stop at:
- a generic summary
- a source list with no judgment
- `资料各有说法`
- pretending the newest source always wins
- presenting inference as if it were evidence
## Core Modes
1. conclusion audit mode
- explain where one conclusion comes from and how strong it is
2. freshness check mode
- judge whether cited material is current enough for the question
3. conflict check mode
- surface where sources disagree and whether the disagreement is material
4. evidence gap mode
- show which important sentences are evidence-backed and which are not
5. source-backed answer mode
- answer the question, but only through an auditable ledger structure
Read [references/audit-heuristics.md](references/audit-heuristics.md) when freshness, evidence grade, or contradiction handling is the hard part.
Read [references/conclusion-cards.md](references/conclusion-cards.md) when the user wants a tighter or more executive-friendly audit card.
## Input Handling
Common inputs:
- copied notes or summaries
- multiple documents
- tables, bullets, screenshots, or connector results
- research outputs with mixed dates
- policy docs, product docs, and commentary mixed together
- earlier AI summaries that now need to be checked
Normalize messy inputs, but do not fake precision the material does not support.
If the material is thin:
- say the evidence is thin
- reduce the strength of the final judgment
- recommend the single best next check
If the material is undated:
- say it is undated
- do not invent freshness confidence
If the material contains only one source:
- give a source-backed answer
- but state clearly that there is no cross-source corroboration
## Core Workflow
1. Define the audit target.
Decide:
- what exact claim or question is under review
- whether the user wants traceability, freshness, conflict resolution, or a final answer
2. Build the source ledger.
For each relevant source, capture:
- what it says
- what claim it supports or weakens
- whether it is primary, derivative, dated, or undated when that is knowable
3. Grade the support.
Separate:
- direct evidence
- corroborated evidence
- inference
- assumption
- unknown
4. Judge freshness.
Ask:
- which cited source is newest
- which is oldest
- whether any source appears stale for this claim
- whether recency changes the answer or only changes confidence
5. Surface conflict.
Explain:
- which sources disagree
- what exactly they disagree about
- whether the conflict is factual, scope-based, time-based, or definitional
- whether the conflict changes the best current judgment
6. Make the smallest honest call.
End with:
- the best current judgment
- why it is the best-supported call right now
- what would change that call
- the next reliable step if uncertainty still matters
## Audit Rules
### Evidence Before Eloquence
Do not make the answer sound cleaner than the sources are.
If the record is messy, the audit should stay honest about that.
### Label Inference Plainly
Preferred phrasing:
- `这部分有直接证据支持。`
- `这个判断来自多份资料的共同指向。`
- `这里更像推断,不是资料直接结论。`
- `这一步目前还是假设。`
### Recency Is Claim-Specific
Do not treat freshness as a global property of a file.
A source can be recent on one point and stale on another.
### Newer Does Not Automatically Beat Better
When two sources disagree, consider:
- source type
- directness
- scope
- date
A dated primary record can outrank a newer derivative summary.
### Conflict Must Stay Visible
Do not merge disagreement into fake consensus.
Good wording:
- `冲突点在时间窗口,不在结论方向。`
- `两份资料对同一事实给出了不同版本。`
- `分歧主要来自定义不同,不一定是真正对打。`
### The Final Call Must Match The Evidence
If the support is strong enough, make the call.
If it is not, narrow the claim instead of hiding.
Good endings:
- `当前最稳的判断是……`
- `能确定到这里,再往后就是推断。`
- `现在可以先下这个小判断,完整判断还差一项核对。`
## Output Pattern
Use this structure unless the user asks for something shorter:
### Question Or Claim
State the exact thing being audited.
### Best Current Judgment
Give the most reliable answer first.
### Source Ledger
List the key sources, usually 2 to 5, and for each one show:
- what it supports
- whether it weakens another claim
- whether it is newest, oldest, undated, or likely stale
### Oldest Or Stale Signal
Call out the source that most threatens freshness confidence.
### Where Sources Conflict
Name the disagreement directly and say whether it changes the current judgment.
### Evidence Vs Inference
Separate what is directly supported from what is inferred.
### What Would Change The Call
State the single fact or source update most likely to change the answer.
### Next Reliable Step
Give the next check, decision, or escalation.
## Finish Standard
When this skill is done well, the user should be able to say:
- I know where this answer came from
- I know which source is oldest
- I know what still conflicts
- I know what is evidence and what is inference
- I know the most reliable judgment I can make now
FILE:CHANGELOG.md
# Changelog
## 1.0.0
Release theme: 从“能连接知识”升级到“能审计结论”。
What changed:
- 发布 `ContextLedger / 知识账本`,明确定位为知识审计层而不是知识库
- 支持把一个结论拆回到 2 到 5 份关键来源,标出 newest / oldest / undated / likely stale
- 强化证据分级,区分 direct evidence、corroborated evidence、inference、assumption、unknown
- 强化冲突表达,要求把 factual、time、scope、definition 等冲突留在结果里,而不是抹平
- 默认输出证据账本式结果:来源、时效、冲突、证据与推断、当前最稳判断、下一步核查
Suggested one-line changelog:
- Launched ContextLedger, an evidence-first knowledge audit skill that traces sources, judges freshness, surfaces conflicts, and separates evidence from inference.
FILE:README.md
# ContextLedger
给知识加上“审计能力”。
`ContextLedger / 知识账本` 不是再做一个知识库,也不是把资料重新总结一遍。它解决的是更靠近“可信结论”的那一步:
- 这个结论到底来自哪几份资料
- 哪一份最旧,哪一份可能已经过时
- 哪两份资料在互相冲突
- 哪些地方是直接证据,哪些只是推断
- 目前最可靠的判断到底是什么
一句话说,它把知识连接,从“能连起来”升级成“能追溯、能判断、能审计”。
## 它适合什么问题
- “这个说法是从哪来的?”
- “哪份资料已经旧了?”
- “这些文件互相矛盾,应该信谁?”
- “不要长摘要,给我证据账本。”
- “哪些句子是事实,哪些只是推断?”
- “我想知道现在最稳的判断,不要装得很确定。”
## 最适合的输入
- 研究笔记
- 多份文档或报告
- 会议纪要
- connector 输出
- 混合了不同日期的资料
- 之前已经写好的 AI 摘要,现在想反查依据
## 默认会输出什么
它默认不追求长文,而是交付一张审计卡:
- `Question Or Claim`
- `Best Current Judgment`
- `Source Ledger`
- `Oldest Or Stale Signal`
- `Where Sources Conflict`
- `Evidence Vs Inference`
- `What Would Change The Call`
- `Next Reliable Step`
也就是说,它的价值不是“再解释一遍”,而是把结论背后的出处、时效、冲突和可信边界讲清楚。
## 和相邻 skill 的边界
- `Knowledge Connector`:负责导入、连接、搜索、关系理解
- `ContextLedger`:负责来源追踪、时效判断、冲突提示、证据分级
- `DecisionDeck`:负责把审计后的材料压成一页式决策简报
- `NextFromKnowledge`:负责把审计后的材料转成下一步动作或计划
如果用户要的是“这条结论到底靠不靠谱、旧不旧、冲不冲突”,更适合 `ContextLedger`。
## 为什么这条产品线值得做
很多知识类 skill 会停在“能接资料、能搜资料、能总结资料”。
但真正影响用户是否信任答案的,通常是另外三件事:
- 来源能不能追溯
- 资料是不是还新
- 不同资料打架时有没有被明确指出
`ContextLedger` 做的就是这层价值:不是让知识更多,而是让结论更可检查。
## 典型提示词
- `用知识账本帮我看这个结论到底来自哪几份资料`
- `把这几份文档做成证据卡,告诉我哪份最旧`
- `这些资料互相冲突,帮我标出冲突点和当前最稳判断`
- `不要长摘要,区分哪些是证据,哪些只是推断`
- `帮我审计这份 AI 总结,看看哪些句子没有来源支撑`
## 安装
```bash
clawhub install contextledger
```
## 适合放在列表页的短介绍
证据优先的知识审计卡,帮你追踪结论来源、判断资料时效、标记冲突,并区分证据和推断。
## 一句话卖点
不是再连更多来源,而是给知识加上审计能力。
FILE:RELEASE.md
# ContextLedger Release Notes
## Short Description
证据优先的知识审计卡,帮你追踪结论来源、判断资料时效、标记冲突,并区分证据和推断。
## Marketplace Card Copy
Title:
- 知识账本
Alternate title:
- ContextLedger
Short description:
- 证据优先的知识审计卡,帮你追踪结论来源、判断资料时效、标记冲突,并区分证据和推断
Install hook:
- 不是再做一遍总结,而是把结论背后的来源、时效和冲突讲清楚
## Announcement Copy
`知识账本(ContextLedger)` 不是一个新的知识库。
它解决的是一个更关键但常被忽略的问题:
当我们已经接了很多资料、写出了很多总结以后,
这条结论到底:
- 是从哪来的
- 还新不新
- 和别的资料冲不冲突
- 哪些地方是证据,哪些地方只是推断
所以它的定位很明确:
`不是再连更多来源,而是给知识加上审计能力。`
默认它会给出一张证据账本式结果:
- Best Current Judgment
- Source Ledger
- Oldest Or Stale Signal
- Where Sources Conflict
- Evidence Vs Inference
- What Would Change The Call
- Next Reliable Step
也就是说,它不是在重复总结,而是在回答“这个总结值不值得信”。
## Official Launch Post
今天做了一个我很喜欢的新 OpenClaw skill:`知识账本(ContextLedger)`。
很多知识类 skill 会停在这几层:
- 把资料接进来
- 把信息连起来
- 把内容总结出来
但真正影响用户是否信任一个结论的,往往是再下一层问题:
- 这个结论到底来自哪几份资料
- 哪份资料已经旧了
- 哪两份资料其实在互相打架
- 哪句话是证据,哪句话只是推断
- 现在最可靠的判断到底是什么
所以 `知识账本` 的目标不是“让我知道更多”,而是“让我知道这条结论能不能被检查”。
我给它定的一句话定位是:
`给知识加上审计能力。`
如果你也经常遇到这些情况:
- 信息很多,但不知道结论从哪来
- 摘要很顺,但不确定证据够不够
- 资料有日期差,但没人提醒你哪里可能过时
- 几份文档明明冲突,却在总结里被抹平了
那这个 skill 会很顺手。
## Suggested Tags
- latest
- knowledge
- knowledge-audit
- evidence-first
- source-traceability
- provenance
- freshness-check
- conflict-detection
- evidence-grading
- research
- trust
## Suggested Repo Name
- `openclaw-skill-contextledger`
## Manual Publish Command
```bash
clawhub publish /absolute/path/to/contextledger \
--slug contextledger \
--name "知识账本" \
--version "1.0.0" \
--changelog "Launch 知识账本 (ContextLedger), an evidence-first knowledge audit skill that traces which sources support a conclusion, flags stale evidence, surfaces conflicts, and separates evidence from inference." \
--tags "latest,knowledge,knowledge-audit,evidence-first,source-traceability,provenance,freshness-check,conflict-detection,evidence-grading,research,trust"
```
FILE:agents/openai.yaml
interface:
display_name: "知识账本"
short_description: "证据优先的知识审计卡,标记来源、时效、冲突与推断路径"
default_prompt: "Use $contextledger to audit my notes, docs, research, or connector outputs, show which sources support each conclusion, which cited source is oldest or stale, where sources conflict, what is evidence versus inference, and answer in Chinese."
FILE:clawhub.json
{
"name": "contextledger",
"version": "1.0.0",
"description": "知识账本 - 证据优先的知识审计 skill,追踪结论来源、判断资料时效、标记来源冲突,并区分证据与推断。",
"keywords": [
"contextledger",
"knowledge-audit",
"evidence-first",
"source-traceability",
"provenance",
"freshness-check",
"staleness-detection",
"conflict-detection",
"evidence-grading",
"inference-labeling",
"research",
"knowledge",
"knowledge-connector",
"trust",
"audit-card"
],
"author": "openclaw",
"license": "MIT"
}
FILE:references/audit-heuristics.md
# Audit Heuristics
Use these heuristics when the hard part is not summarization, but evidence discipline.
## Support Levels
### Direct Evidence
Use when the source explicitly states the claim or directly records the fact.
Good phrasing:
- `资料直接写明了这一点。`
- `这属于直接证据,不需要额外推断。`
### Corroborated Evidence
Use when several independent or differently-situated sources point to the same conclusion.
Good phrasing:
- `多份资料共同支持这个判断。`
- `这不是单一来源的说法。`
### Inference
Use when the conclusion is reasonable, but the sources do not state it outright.
Good phrasing:
- `这个结论更像推断。`
- `资料提供了信号,但没有直接下这个结论。`
### Assumption
Use when the claim is plausible, but the current material does not really support it.
Good phrasing:
- `这一步还是假设。`
- `目前没有足够证据把它当成结论。`
### Unknown
Use when the answer depends on a fact the material does not provide.
Good phrasing:
- `关键事实缺失。`
- `这部分现在无法从现有资料判断。`
## Freshness Rules
1. Prefer explicit dates over implied recency.
2. Mark undated material as undated instead of guessing.
3. Judge freshness against the claim, not against the file as a whole.
4. Call out the oldest cited source when it materially affects confidence.
5. Treat a source as likely stale when:
- a newer source addresses the same point differently
- the source refers to an old version, policy, or state
- the user is asking a time-sensitive question and the source date is too old for that context
6. Do not say `latest` or `current` unless the source itself or the retrieval context supports that wording.
Useful wording:
- `这份是当前引用里最旧的一份。`
- `这份资料未标日期,所以新鲜度无法确认。`
- `这里的新旧差异会直接影响结论。`
- `这份可能已经过时,但还不等于被完全推翻。`
## Conflict Types
### Factual Conflict
Two sources state different facts about the same thing.
### Time Conflict
Two sources may both be correct, but for different time windows.
### Scope Conflict
One source is talking about a narrower or broader case than the other.
### Definition Conflict
The disagreement comes from different terms, metrics, or boundaries.
### Recommendation Conflict
The facts may overlap, but the proposed action differs.
Useful wording:
- `冲突在事实层。`
- `冲突主要来自时间窗口不同。`
- `两边说的不是同一个范围。`
- `这里更像定义不一致,不是硬冲突。`
## Reliability Ordering
Do not use rigid scoring.
Instead, compare sources along these dimensions:
- directness
- date clarity
- primary vs derivative status
- specificity
- relevance to the exact claim
Preferred reasoning:
- `更新,但更像二手转述。`
- `更老,但它更直接回答这个问题。`
- `虽然时间近,但只覆盖局部情况。`
## How To Make The Final Call
1. If the evidence is strong and conflict is immaterial, give the answer directly.
2. If the evidence is mixed, narrow the claim to the part that is actually supported.
3. If conflict is unresolved and material, give a provisional answer plus the best next check.
4. If the evidence is thin, say so plainly and recommend one concrete verification step.
FILE:references/conclusion-cards.md
# Conclusion Cards
Use these frames when the user wants an audit result that reads like a product card instead of a memo.
## Standard Evidence Card
Best when the user wants traceability plus a usable judgment.
```md
### Question Or Claim
### Best Current Judgment
### Source Ledger
- Source A:
- Source B:
- Source C:
### Oldest Or Stale Signal
### Where Sources Conflict
### Evidence Vs Inference
### What Would Change The Call
### Next Reliable Step
```
## Freshness-Heavy Card
Best when the user mainly worries about whether the material is old.
```md
### Current Best Read
### Newest Supporting Source
### Oldest Supporting Source
### Likely Stale Point
### What Still Holds Even If The Old Source Is Wrong
### Next Refresh Step
```
## Conflict-Heavy Card
Best when several documents obviously disagree.
```md
### Best Current Judgment
### Agreement Zone
### Main Conflict
### Why They Conflict
### Which Side Is Better Supported Right Now
### What Is Still Only Inference
### Next Deciding Check
```
## Tone Rules
- Start with the best current judgment, not with background.
- Keep the source list short and decision-relevant.
- When evidence is weak, lower certainty instead of padding the answer.
- Do not hide contradiction to make the card look neat.
- Prefer `目前最稳的判断` over `最终结论` when the material is still moving.
Purchase-timing decision skill for mainland China shopping that judges whether a current price is a short-term low, a fair buy, a wait candidate, or not wort...
---
name: PriceTide
slug: pricetide
version: 1.0.0
description: Purchase-timing decision skill for mainland China shopping that judges whether a current price is a short-term low, a fair buy, a wait candidate, or not worth chasing at all by combining current payable price, recent price pattern, promotion rhythm, urgency, and the downside of waiting.
metadata:
clawdbot:
emoji: "🌊"
requires:
bins: []
os: ["linux", "darwin", "win32"]
---
# PriceTide
One-line positioning:
It does not answer `where is it cheaper`.
It answers `should I buy now or wait`.
PriceTide is the timing layer above Taobao, Tmall, JD, PDD, VIPSHOP, Meituan, elm, and similar shopping channels.
Its job is to help the user answer:
- 现在买还是再等等
- 这是不是短期低点
- 值不值得为了更低价再等一轮
- 是立即下单,还是先关注 / 等活动 / 设提醒
- 这个价格只是便宜,还是已经接近能出手的窗口
This skill should feel like a decisive shopping timing strategist, not a generic price-history explainer.
## Core Output
By default, the answer should converge to one of these four verdicts:
- `现在买`
- `等等看`
- `先关注,等活动`
- `不值得追这个价`
These verdicts matter more than long background explanation.
If helpful, expand them into practical versions such as:
- `现在买,这价已经接近短期低点。`
- `等等看,这波像日常促销,不像好窗口。`
- `先关注,等下一轮活动更合理。`
- `不值得追这个价,便宜得不够干净也不够深。`
## Core Positioning
PriceTide upgrades shopping advice from space to time.
Keep the boundary clear:
- `Worth Buying` answers `买哪个更值`
- `Buying` answers `在哪个平台买`
- `CartPilot` answers `怎么下单最划算`
- `ShopGuard` answers `这条购买路径安不安全`
- `Platform Promo Radar` answers `最近哪些活动值得看`
- `PriceTide` answers `现在是不是出手时机`
It should sit between discovery and checkout:
- after the user has a product in mind
- before the user commits to buying now
- when timing regret is the real problem
## When To Use It
Use this skill when the user says things like:
- `这个价现在能买吗`
- `现在下单还是等 618 / 双11 / 品牌日`
- `这是不是最近低点`
- `要不要为了优惠再等等`
- `这波是活动价还是正常价`
- `帮我判断现在买会不会站岗`
- `别告诉我哪里便宜,告诉我现在该不该买`
- `我先买还是先加关注`
It is strongest when the user gives:
- one exact product or SKU
- the current payable price
- recent price screenshots or price-history clues
- visible promotion mechanics
- urgency such as `刚需`, `送礼`, `还能等`
- a target buy-in price or psychological line
## What This Skill Must Do
Default to these jobs:
- judge where the current price sits in the recent timing cycle
- estimate whether waiting is likely to produce a meaningfully better window
- compare expected future savings versus the cost of waiting
- separate a real buy window from fake urgency or fake discount
- tell the user what to do next: buy now, wait, watch, or stop chasing
Do not stop at:
- a raw price chart summary
- `历史低价是多少`
- `可能还会降`
- `看你需不需要`
Always convert timing analysis into an action.
## Timing Standard
Do not judge the moment by headline discount alone.
Judge timing by:
- current final payable price, not poster price
- current price versus recent normal selling price
- current price versus visible recent low points or known activity lows
- whether the price improvement needs awkward conditions
- platform or category promotion rhythm
- urgency and replacement difficulty
- stock, color, size, or version risk if the user waits
A price can be low but still not be a buy signal if:
- the item itself is not a strong value
- the low price depends on painful coupon stacking
- the next better window is near and realistic
- the user is forcing themselves to buy because of countdown pressure
## If Exact History Is Missing
Do not invent precise history.
If the user does not provide a clean price-history chart, infer directionally from:
- visible current promotion depth
- whether this looks like preheat, main sale, or trailing cleanup
- platform rhythm and category rhythm
- whether similar listings are clustered around the same price
- whether the seller is using a fake crossed-out original price
- whether the current discount looks organic or engineered
Label timing confidence clearly:
- `confirmed by visible history`
- `directional inference from current signals`
- `timing confidence is low because clean history is missing`
## Common Modes
1. exact SKU timing call
- one product, one current price, and the user wants a buy-now or wait-now answer
2. price-history interpretation
- the user gives a chart, screenshot, or recent prices and wants a timing verdict
3. event-wait decision
- the user wants to know whether to wait for 618, 双11, brand day, subsidy refresh, or another sale window
4. anti-regret mode
- the user fears buying today and seeing a better price soon after
5. watchlist mode
- the user is not ready to buy and wants a sensible trigger price or trigger window to watch
## Inputs
Useful inputs include:
- product links
- screenshots
- product title, SKU, spec, color, and version
- current payable price
- past prices, if known
- campaign timing clues
- coupon and subsidy details
- seller type
- urgency or deadline
- target budget or ideal buy-in line
If details are incomplete, prioritize clarifying or inferring:
- exact SKU
- true final payable price
- whether the user is a rigid now-buyer or a flexible watcher
- whether a near-term sales window is actually relevant
If an assumption matters, state it.
## Core Workflow
1. Identify the timing problem.
- pure low-price chase
- normal buy timing
- event wait decision
- anti-regret check
2. Normalize the current price.
- final payable price
- quantity or spec normalization
- coupon or subsidy friction
- whether the price is natural or requires awkward setup
3. Place the current price in context.
- near recent low
- fair but not exciting
- inflated versus recent norm
- fake-deal presentation
4. Estimate waiting value.
- how likely a better window is
- how much lower it may realistically go
- how soon that better window may arrive
- what the user risks by waiting
5. Make the call.
- buy now
- wait
- watch next event
- stop chasing this price
## Decision Rules
### `现在买`
Bias toward `现在买` when most of these are true:
- the current price is already near a visible recent low
- the remaining upside from waiting is small
- the next better window is uncertain or far away
- the user has real urgency or low tolerance for waiting
- inventory, size, color, or official-stock risk rises if the user waits
Good phrasing:
- `这价已经接近能下手的区间,继续等的收益不大。`
- `像短期低点,可以买,不像站岗位。`
- `如果你本来就要买,这波不用硬等。`
### `等等看`
Bias toward `等等看` when:
- the current price is only ordinary, not compelling
- the product has a clear pattern of rotating into better prices
- the user is not urgent
- the expected savings from waiting are meaningful enough
- the price looks like preheat or weak promo rather than a release window
Good phrasing:
- `这价不难看,但也不像低点。`
- `不急的话先等等,站在现在买没有明显优势。`
- `这更像日常促销,不像该出手的节点。`
### `先关注,等活动`
Use this when:
- the current price is not bad, but upcoming campaign timing matters more
- the better window is likely tied to a specific sale phase
- the user is flexible and would benefit from a trigger price or trigger date
- the question is less `买不买` and more `什么时候蹲`
Good phrasing:
- `先加关注,不建议今天硬上。`
- `更像活动前站岗位,真正的买点还没到。`
- `如果不是刚需,等下一轮活动更合理。`
### `不值得追这个价`
Use this when:
- the current price is not genuinely attractive
- the discount quality is weak or fake
- the product or variant itself is a bad chase target
- lower pricing comes with ugly conditions or hidden tradeoffs
- the user is chasing the price story more than the product value
Good phrasing:
- `这价便宜得不够深,也不够干净。`
- `不是不能买,是没必要追这个价。`
- `就算想抄底,这里也不像好的切入点。`
## Waiting Value Rules
Waiting only makes sense when the expected benefit earns the delay.
Judge waiting against:
- possible extra savings
- time until the likely better window
- risk of stock shifts, price bounce, or weaker seller options
- whether the user will keep spending energy tracking a tiny gap
Say it plainly when needed:
- `为了再省这点钱,等的性价比不高。`
- `如果你得一直盯活动,这个等待成本已经不小了。`
- `理论上还能再低一点,但不值得把决策拖成项目。`
## Time Discipline
Timing advice expires quickly.
When live information is involved:
- stamp the snapshot with an exact date, and time when helpful
- separate confirmed facts from timing inferences
- avoid vague words like `最近` or `马上` without anchoring them
Preferred phrasing:
- `截至 2026-04-04 16:30(中国标准时间)`
- `这是基于当前公开价格和活动页面的判断`
- `价格历史未完整可见,以下是方向性判断`
## Output Pattern
Use this structure unless the user asks for something shorter:
### Final Call
Give the verdict immediately: `现在买 / 等等看 / 先关注,等活动 / 不值得追这个价`
### Why This Timing
Explain why the current moment is good, ordinary, weak, or fake-good.
### How Low This Really Looks
State whether the current price looks like:
- short-term low
- fair normal range
- weak promo
- fake discount
### Wait Or Buy Tradeoff
Explain the realistic upside of waiting versus the cost of waiting.
### Next Trigger
Give a practical trigger:
- a target price
- a likely activity phase
- a wait-until date window
- or a condition that would make buying sensible
### Next Step
Tell the user exactly what to do now:
- place the order
- add to watchlist
- wait for the next event
- switch focus to another SKU or route
For short answers, a strong pattern is:
`结论:先关注,等活动。当前价不算难看,但更像日常促销,不像短期低点;如果不急,等下一轮活动再出手更合理。`
## Decision Style
Sound like a decisive Chinese shopping timing advisor.
Preferred phrasing:
- `先说结论,这价能不能下手。`
- `这波像买点,不像站岗位。`
- `别急,这里更像活动前站岗。`
- `如果你不是刚需,今天没有必要硬买。`
- `这个价看着便宜,但不值得专门追。`
- `时间维度上,这一单还没到最好出手点。`
Avoid:
- long neutral summaries with no verdict
- pretending to know exact history when history is missing
- using `看需求` as the ending
- confusing `最低价可能性` with `值得继续等待`
## Browser Workflow
When live validation is needed:
- inspect public product pages, campaign pages, visible coupons, and visible price traces
- compare the current listing with nearby listings or platform phases
- look for whether the activity is preheat, main sale, or tail-end clearance
- stop before login, coupon claiming, cart submission, or payment
Capture:
- exact snapshot date
- current payable price
- visible promo conditions
- whether history is directly visible or inferred
- next likely watch window, if any
## Safety Boundary
| Action | Agent | User |
|------|-------|------|
| Read public product pages, campaign pages, screenshots, and price clues | yes | - |
| Judge whether to buy now, wait, or watch | yes | - |
| Suggest a target price or next watch window | yes | - |
| Read account-only discounts, set platform reminders, claim coupons automatically | no | yes |
| Submit orders or pay | no | yes |
FILE:CHANGELOG.md
# Changelog
## 1.0.0
Release theme: turn shopping advice into a timing decision instead of a location comparison.
What ships:
- add the new `PriceTide` skill
- position it as a dedicated buy-timing layer for mainland China shopping scenarios
- default the output to four verdicts: `现在买`, `等等看`, `先关注,等活动`, and `不值得追这个价`
- teach the skill to weigh current payable price, recent price clues, promotion rhythm, urgency, and the downside of waiting
- add launch README, release notes, package metadata, and ClawHub publishing materials
- add `scripts/publish.sh` so the skill can be published directly from the skill directory
Suggested one-line changelog:
- Launch PriceTide, a buy-timing decision skill that judges whether the current price is a buy-now window, a wait candidate, an event-watch case, or not worth chasing.
FILE:README.md
# PriceTide
`PriceTide`
中文名:`购买时机官`
一句话定位:
它回答的不是“哪里便宜”,而是“现在该不该买”。
`PriceTide` 是一个购物时机判断 skill,核心不是比平台、比店铺、比凑单路径,而是帮用户做时间维度上的购买决策:
- 现在买还是等等
- 这是不是短期低点
- 值不值得为了更低价再等一轮
- 是立即下单,还是先关注 / 等活动 / 设提醒
- 这个价只是便宜,还是已经接近能出手的窗口
## 核心输出
这个 skill 默认会把答案收敛到四种 verdict:
- `现在买`
- `等等看`
- `先关注,等活动`
- `不值得追这个价`
它不应该停在“历史低价是多少”或“也许还会降”这种信息层。
它要把价格、节奏、等待收益和等待成本一起折算成一个行动建议。
## 适合回答的问题
- “这个价现在能买吗?”
- “现在下单还是等 618 / 双11?”
- “这是不是最近低点?”
- “要不要为了优惠再等等?”
- “这波是活动价还是平时价?”
- “帮我判断现在买会不会站岗。”
- “别告诉我哪里便宜,告诉我现在该不该买。”
## 它和相邻购物 skill 的边界
- `Worth Buying`:回答“买哪个更值”
- `Buying`:回答“在哪个平台买”
- `CartPilot`:回答“怎么下单最划算”
- `ShopGuard`:回答“这条购买路径安不安全”
- `Platform Promo Radar`:回答“最近哪些活动值得看”
- `PriceTide`:回答“现在是不是出手时机”
也就是说,它不是空间维度的选择器,而是时间维度的决策器。
## 它怎么帮用户
默认会结合这些信号:
- 当前到手价
- 可见价格历史或价格线索
- 当前活动力度和活动阶段
- 近期大促节奏
- 用户是否刚需
- 等待可能带来的额外收益
- 等待可能带来的成本和风险
最后不只说“价格怎么样”,而是给一个动作:
- 现在下单
- 先等等
- 先加关注等下一轮
- 别再追这个价,换目标更合理
## 为什么这个 skill 值得单独存在
很多购物 skill 解决的是“怎么选”。
`PriceTide` 解决的是更接近真实消费犹豫的那一步:
不是“买哪边”,而是“现在动不动手”。
这类判断对用户非常重要,因为很多后悔并不是买错平台,而是:
- 买早了
- 追了一个并不值得追的价
- 为了省一点钱把自己拖进了长期盯价
- 明明该等活动,却在预热期提前站岗
## 典型输出结构
- `Final Call`
- `Why This Timing`
- `How Low This Really Looks`
- `Wait Or Buy Tradeoff`
- `Next Trigger`
- `Next Step`
## 安全边界
| Action | Agent | User |
|------|-------|------|
| 读取公开商品页、活动页、截图和价格线索 | yes | - |
| 判断现在买、等等、关注活动还是放弃追价 | yes | - |
| 给出目标价或观察窗口 | yes | - |
| 读取账号专属折扣、自动设提醒、自动领券 | no | yes |
| 下单或支付 | no | yes |
## Install
```bash
clawhub install pricetide
```
## 发布材料
```bash
sh ./scripts/publish.sh
```
## 一句话卖点
判断一件商品现在该不该买:识别短期低点、等待价值和活动窗口,把“比价”升级成“买点判断”。
FILE:RELEASE.md
# PriceTide Release Notes
## Short Description
判断商品现在该不该买,识别短期低点、等待价值和活动窗口,而不是只做哪里便宜的比较。
## Marketplace Card Copy
Title:
- PriceTide
Alternate title:
- 购买时机官
Short description:
- 购物时机判断 skill:告诉用户现在买、等等看、先关注等活动,还是不值得追这个价
Install hook:
- 不是比哪里便宜,而是判断现在该不该买
## Announcement Copy
PriceTide 不是再做一个价格比较器。
它解决的是很多用户在购物里更真实、也更高频的犹豫:
- 现在买还是等等
- 这是不是短期低点
- 值不值得为了优惠再等一轮
- 是直接下单,还是先关注下一波活动
也就是说,它把购物建议从“空间维度”升级到了“时间维度”。
相邻 skill 在回答:
- 买哪个更值
- 在哪买更合适
- 怎么下单最划算
而 PriceTide 回答的是:
- 现在是不是该动手
这一版默认把结论收敛到四种 verdict:
- `现在买`
- `等等看`
- `先关注,等活动`
- `不值得追这个价`
它不满足于解释价格,而是把价格线索、活动节奏、等待收益和等待成本一起折算成一个行动建议。
## Suggested Tags
- latest
- shopping
- buy-timing
- price-history
- wait-or-buy
- deal-timing
- ecommerce
- taobao
- jd
- pdd
## Suggested Repo Name
- `openclaw-skill-pricetide`
## Preflight
```bash
cd /absolute/path/to/pricetide
clawhub whoami
bash /absolute/path/to/codex/tmp/validate_clawhub_skill_dir.sh .
```
## Publish Command
### One command
```bash
cd /absolute/path/to/pricetide
sh scripts/publish.sh
```
### Manual command
```bash
clawhub publish /absolute/path/to/pricetide \
--slug pricetide \
--name "PriceTide" \
--version "1.0.0" \
--changelog "Launch PriceTide, a buy-timing decision skill that judges whether the current price is a buy-now window, a wait candidate, an event-watch case, or not worth chasing." \
--tags "latest,shopping,buy-timing,price-history,wait-or-buy,deal-timing,ecommerce,taobao,jd,pdd"
```
FILE:agents/openai.yaml
interface:
display_name: "购买时机官"
short_description: "判断现在该不该买,识别短期低点、等待价值和活动窗口"
default_prompt: "Use $pricetide to judge whether a product should be bought now or later, interpret the current payable price against recent price clues and promotion rhythm, then return one of four verdicts in Chinese: 现在买, 等等看, 先关注,等活动, or 不值得追这个价."
FILE:clawhub.json
{
"name": "pricetide",
"version": "1.0.0",
"description": "PriceTide / 购买时机官 - 判断商品现在该不该买,识别短期低点、等待价值和活动窗口,而不是只做哪里便宜的比较",
"keywords": ["pricetide", "购买时机官", "shopping", "buy-timing", "price-history", "wait-or-buy", "deal-timing", "taobao", "tmall", "jd", "pdd", "vipshop", "meituan", "elm"],
"author": "openclaw",
"license": "MIT"
,
"repository": "https://github.com/harrylabsj/openclaw-skill-pricetide"
}
FILE:package.json
{
"name": "pricetide",
"version": "1.0.0",
"description": "PriceTide - a buy-timing decision skill that tells users whether to buy now, wait, watch the next event, or stop chasing the price",
"main": "SKILL.md",
"scripts": {
"publish:clawhub": "sh scripts/publish.sh"
},
"keywords": [
"pricetide",
"buy-timing",
"price-history",
"wait-or-buy",
"deal-timing",
"shopping",
"taobao",
"tmall",
"jd",
"pdd",
"vipshop",
"meituan",
"elm",
"openclaw-skill"
],
"repository": "https://github.com/harrylabsj/openclaw-skill-pricetide",
"author": "openclaw",
"license": "MIT"
}
FILE:scripts/publish.sh
#!/usr/bin/env sh
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
WORKSPACE_ROOT="$(CDPATH= cd -- "$ROOT/../.." && pwd)"
PACKAGE_JSON="$ROOT/package.json"
CHANGELOG_MD="$ROOT/CHANGELOG.md"
VALIDATOR="$WORKSPACE_ROOT/tmp/validate_clawhub_skill_dir.sh"
VERSION="$(node -e "process.stdout.write(require(process.argv[1]).version)" "$PACKAGE_JSON")"
CHANGELOG="$(node -e "const fs=require('fs'); const text=fs.readFileSync(process.argv[1], 'utf8'); const match=text.match(/Suggested one-line changelog:\\n- (.+)/); process.stdout.write(match ? match[1] : 'Launch PriceTide.');" "$CHANGELOG_MD")"
TAGS="latest,shopping,buy-timing,price-history,wait-or-buy,deal-timing,ecommerce,taobao,jd,pdd"
if ! command -v clawhub >/dev/null 2>&1; then
echo "clawhub CLI not found in PATH" >&2
exit 1
fi
if ! command -v node >/dev/null 2>&1; then
echo "node is required to read version and changelog metadata" >&2
exit 1
fi
if [ -f "$VALIDATOR" ]; then
bash "$VALIDATOR" "$ROOT"
fi
echo "Publishing PriceTide from: $ROOT"
echo "Version: $VERSION"
echo "Tags: $TAGS"
clawhub publish "$ROOT" \
--slug pricetide \
--name "PriceTide" \
--version "$VERSION" \
--changelog "$CHANGELOG" \
--tags "$TAGS"