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/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