jfox-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jfox/__init__.py +5 -0
- jfox/__main__.py +6 -0
- jfox/bm25_index.py +388 -0
- jfox/cli.py +1895 -0
- jfox/config.py +180 -0
- jfox/embedding_backend.py +65 -0
- jfox/formatters.py +305 -0
- jfox/global_config.py +281 -0
- jfox/graph.py +331 -0
- jfox/indexer.py +366 -0
- jfox/kb_manager.py +316 -0
- jfox/models.py +144 -0
- jfox/note.py +464 -0
- jfox/performance.py +408 -0
- jfox/search_engine.py +237 -0
- jfox/template.py +301 -0
- jfox/template_cli.py +327 -0
- jfox/vector_store.py +200 -0
- jfox_cli-0.1.0.dist-info/METADATA +637 -0
- jfox_cli-0.1.0.dist-info/RECORD +22 -0
- jfox_cli-0.1.0.dist-info/WHEEL +4 -0
- jfox_cli-0.1.0.dist-info/entry_points.txt +2 -0
jfox/models.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""笔记数据模型"""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Optional, Dict, Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NoteType(Enum):
|
|
14
|
+
"""笔记类型"""
|
|
15
|
+
FLEETING = "fleeting" # 闪念笔记
|
|
16
|
+
LITERATURE = "literature" # 文献笔记
|
|
17
|
+
PERMANENT = "permanent" # 永久笔记
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Note:
|
|
22
|
+
"""知识卡片模型"""
|
|
23
|
+
id: str # 时间戳 ID (20250322143022)
|
|
24
|
+
title: str # 标题
|
|
25
|
+
content: str # 内容 (Markdown)
|
|
26
|
+
type: NoteType # 类型
|
|
27
|
+
created: datetime # 创建时间
|
|
28
|
+
updated: datetime # 更新时间
|
|
29
|
+
tags: List[str] = field(default_factory=list)
|
|
30
|
+
links: List[str] = field(default_factory=list) # 正向链接
|
|
31
|
+
backlinks: List[str] = field(default_factory=list) # 反向链接
|
|
32
|
+
source: Optional[str] = None # 来源(文献笔记)
|
|
33
|
+
|
|
34
|
+
# 运行时字段(不持久化到 frontmatter)
|
|
35
|
+
embedding: Optional[List[float]] = None # 向量
|
|
36
|
+
score: Optional[float] = None # 检索得分
|
|
37
|
+
hop: Optional[int] = None # 图谱距离
|
|
38
|
+
_filepath: Optional[Path] = None # 自定义文件路径(覆盖默认)
|
|
39
|
+
|
|
40
|
+
def set_filepath(self, path: Path):
|
|
41
|
+
"""设置自定义文件路径(用于测试)"""
|
|
42
|
+
self._filepath = path
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def filename(self) -> str:
|
|
46
|
+
"""生成文件名"""
|
|
47
|
+
if self.type == NoteType.FLEETING:
|
|
48
|
+
return f"{self.id[:8]}-{self.id[8:]}.md"
|
|
49
|
+
else:
|
|
50
|
+
slug = self.title.lower().replace(" ", "-")[:50]
|
|
51
|
+
# 移除特殊字符
|
|
52
|
+
slug = re.sub(r'[^\w\-]', '', slug)
|
|
53
|
+
return f"{self.id}-{slug}.md"
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def filepath(self) -> Path:
|
|
57
|
+
"""完整文件路径"""
|
|
58
|
+
# 如果设置了自定义路径,优先使用
|
|
59
|
+
if self._filepath is not None:
|
|
60
|
+
return self._filepath
|
|
61
|
+
|
|
62
|
+
from .config import config
|
|
63
|
+
base = config.notes_dir / self.type.value
|
|
64
|
+
return base / self.filename
|
|
65
|
+
|
|
66
|
+
def to_markdown(self) -> str:
|
|
67
|
+
"""转换为 Markdown 格式"""
|
|
68
|
+
frontmatter = {
|
|
69
|
+
"id": self.id,
|
|
70
|
+
"title": self.title,
|
|
71
|
+
"type": self.type.value,
|
|
72
|
+
"created": self.created.isoformat(),
|
|
73
|
+
"updated": self.updated.isoformat(),
|
|
74
|
+
"tags": self.tags,
|
|
75
|
+
"links": self.links,
|
|
76
|
+
"backlinks": self.backlinks,
|
|
77
|
+
}
|
|
78
|
+
if self.source:
|
|
79
|
+
frontmatter["source"] = self.source
|
|
80
|
+
|
|
81
|
+
fm_yaml = yaml.dump(frontmatter, allow_unicode=True, sort_keys=False)
|
|
82
|
+
|
|
83
|
+
return f"---\n{fm_yaml}---\n\n# {self.title}\n\n{self.content}\n"
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_markdown(cls, content: str, filepath: Path) -> "Note":
|
|
87
|
+
"""从 Markdown 解析"""
|
|
88
|
+
# 解析 frontmatter
|
|
89
|
+
match = re.match(r'^---\n(.*?)\n---\n+(.*)', content, re.DOTALL)
|
|
90
|
+
if not match:
|
|
91
|
+
raise ValueError("Invalid markdown format: missing frontmatter")
|
|
92
|
+
|
|
93
|
+
fm = yaml.safe_load(match.group(1))
|
|
94
|
+
body = match.group(2)
|
|
95
|
+
|
|
96
|
+
# 提取标题
|
|
97
|
+
title_match = re.search(r'^# (.+)$', body, re.MULTILINE)
|
|
98
|
+
title = title_match.group(1) if title_match else fm.get("title", "Untitled")
|
|
99
|
+
|
|
100
|
+
# 提取内容(去除标题)
|
|
101
|
+
content_text = re.sub(r'^# .+\n+', '', body, count=1)
|
|
102
|
+
|
|
103
|
+
# 解析时间
|
|
104
|
+
created_str = fm.get("created", datetime.now().isoformat())
|
|
105
|
+
updated_str = fm.get("updated", datetime.now().isoformat())
|
|
106
|
+
|
|
107
|
+
if isinstance(created_str, datetime):
|
|
108
|
+
created = created_str
|
|
109
|
+
else:
|
|
110
|
+
created = datetime.fromisoformat(created_str)
|
|
111
|
+
|
|
112
|
+
if isinstance(updated_str, datetime):
|
|
113
|
+
updated = updated_str
|
|
114
|
+
else:
|
|
115
|
+
updated = datetime.fromisoformat(updated_str)
|
|
116
|
+
|
|
117
|
+
return cls(
|
|
118
|
+
id=fm.get("id", ""),
|
|
119
|
+
title=fm.get("title", title),
|
|
120
|
+
content=content_text.strip(),
|
|
121
|
+
type=NoteType(fm.get("type", "fleeting")),
|
|
122
|
+
created=created,
|
|
123
|
+
updated=updated,
|
|
124
|
+
tags=fm.get("tags", []),
|
|
125
|
+
links=fm.get("links", []),
|
|
126
|
+
backlinks=fm.get("backlinks", []),
|
|
127
|
+
source=fm.get("source"),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
131
|
+
"""转换为字典(用于 JSON 输出)"""
|
|
132
|
+
return {
|
|
133
|
+
"id": self.id,
|
|
134
|
+
"title": self.title,
|
|
135
|
+
"content": self.content[:200] + "..." if len(self.content) > 200 else self.content,
|
|
136
|
+
"type": self.type.value,
|
|
137
|
+
"created": self.created.isoformat(),
|
|
138
|
+
"updated": self.updated.isoformat(),
|
|
139
|
+
"tags": self.tags,
|
|
140
|
+
"links": self.links,
|
|
141
|
+
"filepath": str(self.filepath),
|
|
142
|
+
"score": self.score,
|
|
143
|
+
"hop": self.hop,
|
|
144
|
+
}
|
jfox/note.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""笔记 CRUD 操作"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional, Dict, Any
|
|
8
|
+
|
|
9
|
+
from .models import Note, NoteType
|
|
10
|
+
from .config import config, ZKConfig
|
|
11
|
+
from .vector_store import get_vector_store
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def generate_id() -> str:
|
|
17
|
+
"""
|
|
18
|
+
生成唯一 ID
|
|
19
|
+
|
|
20
|
+
格式: 时间戳 + 4位随机数 (共18位)
|
|
21
|
+
例如: 202603242325281234
|
|
22
|
+
"""
|
|
23
|
+
import random
|
|
24
|
+
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
25
|
+
random_suffix = random.randint(0, 9999)
|
|
26
|
+
return f"{timestamp}{random_suffix:04d}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_note(
|
|
30
|
+
content: str,
|
|
31
|
+
title: Optional[str] = None,
|
|
32
|
+
note_type: NoteType = NoteType.FLEETING,
|
|
33
|
+
tags: Optional[List[str]] = None,
|
|
34
|
+
links: Optional[List[str]] = None,
|
|
35
|
+
source: Optional[str] = None,
|
|
36
|
+
) -> Note:
|
|
37
|
+
"""创建新笔记"""
|
|
38
|
+
note_id = generate_id()
|
|
39
|
+
now = datetime.now()
|
|
40
|
+
|
|
41
|
+
# 如果没有标题,从内容提取
|
|
42
|
+
if title is None:
|
|
43
|
+
title = content[:50] + "..." if len(content) > 50 else content
|
|
44
|
+
|
|
45
|
+
note = Note(
|
|
46
|
+
id=note_id,
|
|
47
|
+
title=title,
|
|
48
|
+
content=content,
|
|
49
|
+
type=note_type,
|
|
50
|
+
created=now,
|
|
51
|
+
updated=now,
|
|
52
|
+
tags=tags or [],
|
|
53
|
+
links=links or [],
|
|
54
|
+
backlinks=[],
|
|
55
|
+
source=source,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return note
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def save_note(note: Note, add_to_index: bool = True) -> bool:
|
|
62
|
+
"""保存笔记到文件"""
|
|
63
|
+
try:
|
|
64
|
+
# 确保目录存在
|
|
65
|
+
note.filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
# 写入文件
|
|
68
|
+
with open(note.filepath, 'w', encoding='utf-8') as f:
|
|
69
|
+
f.write(note.to_markdown())
|
|
70
|
+
|
|
71
|
+
logger.info(f"Saved note to {note.filepath}")
|
|
72
|
+
|
|
73
|
+
# 添加到向量索引
|
|
74
|
+
if add_to_index:
|
|
75
|
+
vector_store = get_vector_store()
|
|
76
|
+
vector_store.add_note(note)
|
|
77
|
+
|
|
78
|
+
# 添加到 BM25 索引
|
|
79
|
+
try:
|
|
80
|
+
from .bm25_index import get_bm25_index
|
|
81
|
+
bm25_index = get_bm25_index()
|
|
82
|
+
content = f"{note.title} {note.content}"
|
|
83
|
+
bm25_index.add_document(note.id, content)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.warning(f"Failed to add note to BM25 index: {e}")
|
|
86
|
+
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
except Exception as e:
|
|
90
|
+
logger.error(f"Failed to save note: {e}")
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_note(filepath: Path) -> Optional[Note]:
|
|
95
|
+
"""从文件加载笔记"""
|
|
96
|
+
try:
|
|
97
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
98
|
+
content = f.read()
|
|
99
|
+
|
|
100
|
+
return Note.from_markdown(content, filepath)
|
|
101
|
+
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.error(f"Failed to load note from {filepath}: {e}")
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_note_by_id(note_id: str, cfg: Optional[ZKConfig] = None) -> Optional[Note]:
|
|
108
|
+
"""
|
|
109
|
+
通过 ID 加载笔记
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
note_id: 笔记 ID
|
|
113
|
+
cfg: 可选的配置对象,默认使用全局 config
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Note 对象或 None
|
|
117
|
+
"""
|
|
118
|
+
use_config = cfg or config
|
|
119
|
+
|
|
120
|
+
# 在所有类型目录中搜索
|
|
121
|
+
for note_type in NoteType:
|
|
122
|
+
dir_path = use_config.notes_dir / note_type.value
|
|
123
|
+
if not dir_path.exists():
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
for filepath in dir_path.glob(f"{note_id}*.md"):
|
|
127
|
+
return load_note(filepath)
|
|
128
|
+
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def list_notes(
|
|
133
|
+
note_type: Optional[NoteType] = None,
|
|
134
|
+
limit: Optional[int] = None,
|
|
135
|
+
cfg: Optional[ZKConfig] = None,
|
|
136
|
+
) -> List[Note]:
|
|
137
|
+
"""
|
|
138
|
+
列出笔记
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
note_type: 笔记类型筛选
|
|
142
|
+
limit: 数量限制
|
|
143
|
+
cfg: 可选的配置对象,默认使用全局 config
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
笔记列表
|
|
147
|
+
"""
|
|
148
|
+
use_config = cfg or config
|
|
149
|
+
notes = []
|
|
150
|
+
|
|
151
|
+
types_to_list = [note_type] if note_type else list(NoteType)
|
|
152
|
+
|
|
153
|
+
for nt in types_to_list:
|
|
154
|
+
dir_path = use_config.notes_dir / nt.value
|
|
155
|
+
if not dir_path.exists():
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
for filepath in sorted(dir_path.glob("*.md"), reverse=True):
|
|
159
|
+
note = load_note(filepath)
|
|
160
|
+
if note:
|
|
161
|
+
notes.append(note)
|
|
162
|
+
|
|
163
|
+
if limit and len(notes) >= limit:
|
|
164
|
+
break
|
|
165
|
+
|
|
166
|
+
if limit and len(notes) >= limit:
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
return notes
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def delete_note(note_id: str) -> bool:
|
|
173
|
+
"""删除笔记"""
|
|
174
|
+
note = load_note_by_id(note_id)
|
|
175
|
+
if not note:
|
|
176
|
+
logger.warning(f"Note {note_id} not found")
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
# 删除文件
|
|
181
|
+
note.filepath.unlink()
|
|
182
|
+
logger.info(f"Deleted note file: {note.filepath}")
|
|
183
|
+
|
|
184
|
+
# 从向量索引删除
|
|
185
|
+
vector_store = get_vector_store()
|
|
186
|
+
vector_store.delete_note(note_id)
|
|
187
|
+
|
|
188
|
+
# 从 BM25 索引删除
|
|
189
|
+
try:
|
|
190
|
+
from .bm25_index import get_bm25_index
|
|
191
|
+
bm25_index = get_bm25_index()
|
|
192
|
+
bm25_index.remove_document(note_id)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
logger.warning(f"Failed to remove note from BM25 index: {e}")
|
|
195
|
+
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.error(f"Failed to delete note {note_id}: {e}")
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_stats(cfg: Optional[ZKConfig] = None) -> Dict[str, Any]:
|
|
204
|
+
"""
|
|
205
|
+
获取知识库统计
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
cfg: 可选的配置对象,默认使用全局 config
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
统计信息字典
|
|
212
|
+
"""
|
|
213
|
+
use_config = cfg or config
|
|
214
|
+
|
|
215
|
+
stats = {
|
|
216
|
+
"total": 0,
|
|
217
|
+
"by_type": {},
|
|
218
|
+
"vector_store": {},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# 统计各类型笔记数量
|
|
222
|
+
for note_type in NoteType:
|
|
223
|
+
dir_path = use_config.notes_dir / note_type.value
|
|
224
|
+
if dir_path.exists():
|
|
225
|
+
count = len(list(dir_path.glob("*.md")))
|
|
226
|
+
stats["by_type"][note_type.value] = count
|
|
227
|
+
stats["total"] += count
|
|
228
|
+
|
|
229
|
+
# 向量存储统计
|
|
230
|
+
try:
|
|
231
|
+
vector_store = get_vector_store()
|
|
232
|
+
stats["vector_store"] = vector_store.get_stats()
|
|
233
|
+
except Exception as e:
|
|
234
|
+
logger.warning(f"Failed to get vector store stats: {e}")
|
|
235
|
+
stats["vector_store"] = {"error": str(e)}
|
|
236
|
+
|
|
237
|
+
return stats
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def search_notes(
|
|
241
|
+
query: str,
|
|
242
|
+
top_k: int = 5,
|
|
243
|
+
note_type: Optional[str] = None,
|
|
244
|
+
mode: str = "hybrid",
|
|
245
|
+
) -> List[Dict[str, Any]]:
|
|
246
|
+
"""
|
|
247
|
+
搜索笔记
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
query: 搜索查询
|
|
251
|
+
top_k: 返回结果数量
|
|
252
|
+
note_type: 笔记类型筛选
|
|
253
|
+
mode: 搜索模式 - "hybrid"(混合), "semantic"(语义), "keyword"(关键词)
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
搜索结果列表
|
|
257
|
+
"""
|
|
258
|
+
from .search_engine import get_search_engine, SearchMode
|
|
259
|
+
|
|
260
|
+
search_engine = get_search_engine()
|
|
261
|
+
|
|
262
|
+
# 转换模式
|
|
263
|
+
mode_map = {
|
|
264
|
+
"hybrid": SearchMode.HYBRID,
|
|
265
|
+
"semantic": SearchMode.SEMANTIC,
|
|
266
|
+
"keyword": SearchMode.KEYWORD,
|
|
267
|
+
}
|
|
268
|
+
search_mode = mode_map.get(mode.lower(), SearchMode.HYBRID)
|
|
269
|
+
|
|
270
|
+
return search_engine.search(query, top_k=top_k, mode=search_mode, note_type=note_type)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def extract_keywords(content: str, max_keywords: int = 10) -> List[str]:
|
|
274
|
+
"""
|
|
275
|
+
从内容中提取关键词
|
|
276
|
+
|
|
277
|
+
简单实现:提取长度在 2-20 之间的单词/词组,排除常见停用词
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
content: 文本内容
|
|
281
|
+
max_keywords: 最大关键词数量
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
关键词列表
|
|
285
|
+
"""
|
|
286
|
+
import re
|
|
287
|
+
|
|
288
|
+
# 常见中文和英文停用词
|
|
289
|
+
stopwords = {
|
|
290
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
291
|
+
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
|
|
292
|
+
'should', 'may', 'might', 'must', 'shall', 'can', 'need', 'dare',
|
|
293
|
+
'ought', 'used', 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by',
|
|
294
|
+
'from', 'as', 'into', 'through', 'during', 'before', 'after', 'above',
|
|
295
|
+
'below', 'between', 'under', 'and', 'but', 'or', 'yet', 'so', 'if',
|
|
296
|
+
'because', 'although', 'though', 'while', 'where', 'when', 'that',
|
|
297
|
+
'which', 'who', 'whom', 'whose', 'what', 'this', 'these', 'those',
|
|
298
|
+
'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'him', 'her',
|
|
299
|
+
'us', 'them', 'my', 'your', 'his', 'its', 'our', 'their', '这里',
|
|
300
|
+
'那里', '这个', '那个', '什么', '怎么', '为什么', '因为', '所以',
|
|
301
|
+
'但是', '如果', '虽然', '而且', '或者', '和', '与', '的', '了',
|
|
302
|
+
'在', '是', '我', '你', '他', '她', '它', '们', '有', '没有',
|
|
303
|
+
'一个', '一种', '一些', '可以', '需要', '应该', '能够', '已经',
|
|
304
|
+
'现在', '今天', '明天', '昨天', '这样', '那样', '如何', '谁',
|
|
305
|
+
'哪', '哪些', '哪里', '什么时候', '怎样', '非常', '很', '太',
|
|
306
|
+
'最', '更', '比较', '相当', '真的', '确实', '当然', '可能',
|
|
307
|
+
'也许', '大概', '一定', '必须', '得', '地', '着', '过', '把',
|
|
308
|
+
'被', '让', '给', '向', '从', '到', '对于', '关于', '由于',
|
|
309
|
+
'根据', '按照', '通过', '随着', '除了', '包括', '涉及', '有关',
|
|
310
|
+
'学习', '使用', '实现', '添加', '创建', '记录', '今天', '一下',
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
# 提取潜在关键词(2-20 个字符的词组)
|
|
314
|
+
# 匹配中文字符串或英文单词
|
|
315
|
+
pattern = r'[\u4e00-\u9fff]{2,10}|[a-zA-Z][a-zA-Z0-9_]{1,15}'
|
|
316
|
+
matches = re.findall(pattern, content.lower())
|
|
317
|
+
|
|
318
|
+
# 统计词频
|
|
319
|
+
word_counts = {}
|
|
320
|
+
for word in matches:
|
|
321
|
+
if word not in stopwords and len(word) >= 2:
|
|
322
|
+
word_counts[word] = word_counts.get(word, 0) + 1
|
|
323
|
+
|
|
324
|
+
# 按词频排序,返回前 max_keywords 个
|
|
325
|
+
sorted_words = sorted(word_counts.items(), key=lambda x: x[1], reverse=True)
|
|
326
|
+
return [word for word, count in sorted_words[:max_keywords]]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def suggest_links(
|
|
330
|
+
content: str,
|
|
331
|
+
top_k: int = 5,
|
|
332
|
+
threshold: float = 0.6,
|
|
333
|
+
exclude_ids: Optional[List[str]] = None,
|
|
334
|
+
cfg: Optional[ZKConfig] = None,
|
|
335
|
+
) -> List[Dict[str, Any]]:
|
|
336
|
+
"""
|
|
337
|
+
根据内容推荐可以链接的已有笔记
|
|
338
|
+
|
|
339
|
+
使用语义相似度 + 关键词匹配的混合策略
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
content: 输入内容
|
|
343
|
+
top_k: 返回建议数量
|
|
344
|
+
threshold: 相似度阈值(0-1)
|
|
345
|
+
exclude_ids: 要排除的笔记 ID 列表
|
|
346
|
+
cfg: 可选的配置对象,默认使用全局 config
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
建议链接的笔记列表,按置信度排序
|
|
350
|
+
"""
|
|
351
|
+
exclude_ids = exclude_ids or []
|
|
352
|
+
suggestions = []
|
|
353
|
+
seen_ids = set(exclude_ids)
|
|
354
|
+
|
|
355
|
+
# 1. 语义搜索 - 获取相似笔记
|
|
356
|
+
try:
|
|
357
|
+
semantic_results = search_notes(content, top_k=top_k * 2)
|
|
358
|
+
for r in semantic_results:
|
|
359
|
+
note_id = r.get("id")
|
|
360
|
+
if note_id and note_id not in seen_ids:
|
|
361
|
+
score = r.get("score", 0)
|
|
362
|
+
if score >= threshold:
|
|
363
|
+
suggestions.append({
|
|
364
|
+
"id": note_id,
|
|
365
|
+
"title": r.get("metadata", {}).get("title", "Untitled"),
|
|
366
|
+
"type": r.get("metadata", {}).get("type", "unknown"),
|
|
367
|
+
"score": round(score, 3),
|
|
368
|
+
"match_type": "semantic",
|
|
369
|
+
"preview": r.get("document", "")[:150] + "..." if r.get("document") else "",
|
|
370
|
+
})
|
|
371
|
+
seen_ids.add(note_id)
|
|
372
|
+
except Exception as e:
|
|
373
|
+
logger.warning(f"Semantic search failed in suggest_links: {e}")
|
|
374
|
+
|
|
375
|
+
# 2. 关键词匹配 - 作为补充
|
|
376
|
+
try:
|
|
377
|
+
keywords = extract_keywords(content, max_keywords=5)
|
|
378
|
+
if keywords:
|
|
379
|
+
all_notes = list_notes(limit=200, cfg=cfg) # 获取足够多的笔记用于匹配
|
|
380
|
+
|
|
381
|
+
for note in all_notes:
|
|
382
|
+
if note.id in seen_ids:
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
# 计算关键词匹配分数
|
|
386
|
+
note_text = f"{note.title} {' '.join(note.tags)} {note.content[:500]}"
|
|
387
|
+
note_text_lower = note_text.lower()
|
|
388
|
+
|
|
389
|
+
match_count = 0
|
|
390
|
+
for kw in keywords:
|
|
391
|
+
if kw.lower() in note_text_lower:
|
|
392
|
+
match_count += 1
|
|
393
|
+
|
|
394
|
+
if match_count > 0:
|
|
395
|
+
# 关键词匹配分数 (0.3 - 0.6)
|
|
396
|
+
keyword_score = 0.3 + (match_count / len(keywords)) * 0.3
|
|
397
|
+
|
|
398
|
+
# 如果分数达到阈值且结果数量不足,添加
|
|
399
|
+
if keyword_score >= threshold * 0.5 and len(suggestions) < top_k * 2:
|
|
400
|
+
suggestions.append({
|
|
401
|
+
"id": note.id,
|
|
402
|
+
"title": note.title,
|
|
403
|
+
"type": note.type.value,
|
|
404
|
+
"score": round(keyword_score, 3),
|
|
405
|
+
"match_type": "keyword",
|
|
406
|
+
"matched_keywords": [kw for kw in keywords if kw.lower() in note_text_lower],
|
|
407
|
+
"preview": note.content[:150] + "..." if note.content else "",
|
|
408
|
+
})
|
|
409
|
+
seen_ids.add(note.id)
|
|
410
|
+
except Exception as e:
|
|
411
|
+
logger.warning(f"Keyword matching failed in suggest_links: {e}")
|
|
412
|
+
|
|
413
|
+
# 3. 按分数排序并返回前 top_k 个
|
|
414
|
+
suggestions.sort(key=lambda x: x["score"], reverse=True)
|
|
415
|
+
return suggestions[:top_k]
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def find_note_file(config_obj, note_id: str) -> Optional[Path]:
|
|
419
|
+
"""
|
|
420
|
+
通过 ID 查找笔记文件路径
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
config_obj: ZKConfig 配置对象
|
|
424
|
+
note_id: 笔记 ID
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
文件路径或 None
|
|
428
|
+
"""
|
|
429
|
+
for note_type in NoteType:
|
|
430
|
+
dir_path = config_obj.notes_dir / note_type.value
|
|
431
|
+
if not dir_path.exists():
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
for filepath in dir_path.glob(f"{note_id}*.md"):
|
|
435
|
+
return filepath
|
|
436
|
+
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class NoteManager:
|
|
441
|
+
"""笔记管理器类,用于面向对象的操作"""
|
|
442
|
+
|
|
443
|
+
@staticmethod
|
|
444
|
+
def load_note(filepath: Path) -> Optional[Note]:
|
|
445
|
+
"""从文件加载笔记"""
|
|
446
|
+
return load_note_static(filepath)
|
|
447
|
+
|
|
448
|
+
@staticmethod
|
|
449
|
+
def find_note_file(config_obj, note_id: str) -> Optional[Path]:
|
|
450
|
+
"""通过 ID 查找笔记文件路径"""
|
|
451
|
+
return find_note_file(config_obj, note_id)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def load_note_static(filepath: Path) -> Optional[Note]:
|
|
455
|
+
"""从文件加载笔记(静态版本)"""
|
|
456
|
+
try:
|
|
457
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
458
|
+
content = f.read()
|
|
459
|
+
|
|
460
|
+
return Note.from_markdown(content, filepath)
|
|
461
|
+
|
|
462
|
+
except Exception as e:
|
|
463
|
+
logger.error(f"Failed to load note from {filepath}: {e}")
|
|
464
|
+
return None
|