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/indexer.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File system indexer with watchdog for auto-indexing Zettelkasten notes.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- Real-time file monitoring
|
|
6
|
+
- Automatic indexing on file changes
|
|
7
|
+
- Batch indexing for initial setup
|
|
8
|
+
- Index status and statistics
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
import threading
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Callable, Dict, List, Optional, Set
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from collections import deque
|
|
19
|
+
|
|
20
|
+
from watchdog.observers import Observer
|
|
21
|
+
from watchdog.events import FileSystemEventHandler, FileModifiedEvent, FileCreatedEvent, FileDeletedEvent
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
24
|
+
|
|
25
|
+
from .config import ZKConfig
|
|
26
|
+
from .models import Note
|
|
27
|
+
from .vector_store import VectorStore
|
|
28
|
+
from .graph import KnowledgeGraph
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class IndexStats:
|
|
36
|
+
"""Statistics for the indexer."""
|
|
37
|
+
total_indexed: int = 0
|
|
38
|
+
last_indexed: Optional[datetime] = None
|
|
39
|
+
pending_changes: int = 0
|
|
40
|
+
errors: List[str] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NoteEventHandler(FileSystemEventHandler):
|
|
44
|
+
"""
|
|
45
|
+
Handles file system events for note files.
|
|
46
|
+
|
|
47
|
+
Debounces rapid file changes and batches indexing operations.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
vector_store: VectorStore,
|
|
53
|
+
config: ZKConfig,
|
|
54
|
+
debounce_seconds: float = 1.0,
|
|
55
|
+
callback: Optional[Callable[[str, str], None]] = None
|
|
56
|
+
):
|
|
57
|
+
self.vector_store = vector_store
|
|
58
|
+
self.config = config
|
|
59
|
+
self.debounce_seconds = debounce_seconds
|
|
60
|
+
self.callback = callback
|
|
61
|
+
|
|
62
|
+
self._pending: Dict[str, float] = {}
|
|
63
|
+
self._lock = threading.Lock()
|
|
64
|
+
self._timer: Optional[threading.Timer] = None
|
|
65
|
+
self.stats = IndexStats()
|
|
66
|
+
|
|
67
|
+
def on_modified(self, event):
|
|
68
|
+
if event.is_directory:
|
|
69
|
+
return
|
|
70
|
+
if not event.src_path.endswith(".md"):
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
with self._lock:
|
|
74
|
+
self._pending[event.src_path] = time.time()
|
|
75
|
+
self._schedule_process()
|
|
76
|
+
|
|
77
|
+
on_created = on_modified # Treat creation same as modification
|
|
78
|
+
|
|
79
|
+
def on_deleted(self, event):
|
|
80
|
+
if event.is_directory:
|
|
81
|
+
return
|
|
82
|
+
if not event.src_path.endswith(".md"):
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# Remove from vector store
|
|
86
|
+
note_id = Path(event.src_path).stem
|
|
87
|
+
try:
|
|
88
|
+
self.vector_store.delete_note(note_id)
|
|
89
|
+
if self.callback:
|
|
90
|
+
self.callback("deleted", note_id)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
self.stats.errors.append(f"Failed to delete {note_id}: {e}")
|
|
93
|
+
|
|
94
|
+
def _schedule_process(self):
|
|
95
|
+
"""Schedule processing of pending changes."""
|
|
96
|
+
if self._timer:
|
|
97
|
+
self._timer.cancel()
|
|
98
|
+
|
|
99
|
+
self._timer = threading.Timer(self.debounce_seconds, self._process_pending)
|
|
100
|
+
self._timer.daemon = True
|
|
101
|
+
self._timer.start()
|
|
102
|
+
|
|
103
|
+
def _process_pending(self):
|
|
104
|
+
"""Process all pending file changes."""
|
|
105
|
+
with self._lock:
|
|
106
|
+
now = time.time()
|
|
107
|
+
ready = [
|
|
108
|
+
path for path, timestamp in self._pending.items()
|
|
109
|
+
if now - timestamp >= self.debounce_seconds
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
for path in ready:
|
|
113
|
+
del self._pending[path]
|
|
114
|
+
|
|
115
|
+
self.stats.pending_changes = len(self._pending)
|
|
116
|
+
|
|
117
|
+
for path in ready:
|
|
118
|
+
self._index_file(path)
|
|
119
|
+
|
|
120
|
+
def _index_file(self, file_path: str):
|
|
121
|
+
"""Index a single note file."""
|
|
122
|
+
from .note import NoteManager
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
note = NoteManager.load_note(Path(file_path))
|
|
126
|
+
if not note:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
# Index in vector store
|
|
130
|
+
self.vector_store.add_or_update_note(note)
|
|
131
|
+
|
|
132
|
+
self.stats.total_indexed += 1
|
|
133
|
+
self.stats.last_indexed = datetime.now()
|
|
134
|
+
|
|
135
|
+
if self.callback:
|
|
136
|
+
self.callback("indexed", note.id)
|
|
137
|
+
|
|
138
|
+
except Exception as e:
|
|
139
|
+
error_msg = f"Failed to index {file_path}: {e}"
|
|
140
|
+
self.stats.errors.append(error_msg)
|
|
141
|
+
if self.callback:
|
|
142
|
+
self.callback("error", error_msg)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Indexer:
|
|
146
|
+
"""
|
|
147
|
+
Manages file system monitoring and indexing for Zettelkasten notes.
|
|
148
|
+
|
|
149
|
+
Usage:
|
|
150
|
+
indexer = Indexer(config, vector_store)
|
|
151
|
+
indexer.start() # Start watching
|
|
152
|
+
...
|
|
153
|
+
indexer.stop() # Stop watching
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
config: ZKConfig,
|
|
159
|
+
vector_store: VectorStore,
|
|
160
|
+
debounce_seconds: float = 1.0
|
|
161
|
+
):
|
|
162
|
+
self.config = config
|
|
163
|
+
self.vector_store = vector_store
|
|
164
|
+
self.debounce_seconds = debounce_seconds
|
|
165
|
+
|
|
166
|
+
self._observer: Optional[Observer] = None
|
|
167
|
+
self._handler: Optional[NoteEventHandler] = None
|
|
168
|
+
self._running = False
|
|
169
|
+
|
|
170
|
+
def start(self, callback: Optional[Callable[[str, str], None]] = None):
|
|
171
|
+
"""
|
|
172
|
+
Start the file system observer.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
callback: Optional callback for events (event_type, message)
|
|
176
|
+
"""
|
|
177
|
+
if self._running:
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
notes_dir = Path(self.config.notes_dir)
|
|
181
|
+
if not notes_dir.exists():
|
|
182
|
+
console.print(f"[yellow]Notes directory not found: {notes_dir}[/yellow]")
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
self._handler = NoteEventHandler(
|
|
186
|
+
self.vector_store,
|
|
187
|
+
self.config,
|
|
188
|
+
self.debounce_seconds,
|
|
189
|
+
callback
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
self._observer = Observer()
|
|
193
|
+
self._observer.schedule(self._handler, str(notes_dir), recursive=True)
|
|
194
|
+
self._observer.start()
|
|
195
|
+
|
|
196
|
+
self._running = True
|
|
197
|
+
console.print(f"[green]Started indexer watching: {notes_dir}[/green]")
|
|
198
|
+
|
|
199
|
+
def stop(self):
|
|
200
|
+
"""Stop the file system observer."""
|
|
201
|
+
if not self._running:
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
if self._observer:
|
|
205
|
+
self._observer.stop()
|
|
206
|
+
self._observer.join()
|
|
207
|
+
|
|
208
|
+
self._running = False
|
|
209
|
+
console.print("[green]Indexer stopped[/green]")
|
|
210
|
+
|
|
211
|
+
def is_running(self) -> bool:
|
|
212
|
+
"""Check if the indexer is running."""
|
|
213
|
+
return self._running
|
|
214
|
+
|
|
215
|
+
def get_stats(self) -> IndexStats:
|
|
216
|
+
"""Get indexer statistics."""
|
|
217
|
+
if self._handler:
|
|
218
|
+
return self._handler.stats
|
|
219
|
+
return IndexStats()
|
|
220
|
+
|
|
221
|
+
def index_all(self, progress_callback: Optional[Callable[[int, int], None]] = None) -> int:
|
|
222
|
+
"""
|
|
223
|
+
Perform full re-indexing of all notes.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
progress_callback: Called with (current, total) for progress updates
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Number of notes indexed
|
|
230
|
+
"""
|
|
231
|
+
from .note import NoteManager
|
|
232
|
+
|
|
233
|
+
notes_dir = Path(self.config.notes_dir)
|
|
234
|
+
if not notes_dir.exists():
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
# Find all note files
|
|
238
|
+
note_files = list(notes_dir.rglob("*.md"))
|
|
239
|
+
total = len(note_files)
|
|
240
|
+
|
|
241
|
+
if total == 0:
|
|
242
|
+
return 0
|
|
243
|
+
|
|
244
|
+
indexed = 0
|
|
245
|
+
|
|
246
|
+
with Progress(
|
|
247
|
+
SpinnerColumn(),
|
|
248
|
+
TextColumn("[progress.description]{task.description}"),
|
|
249
|
+
console=console
|
|
250
|
+
) as progress:
|
|
251
|
+
task = progress.add_task(f"Indexing {total} notes...", total=total)
|
|
252
|
+
|
|
253
|
+
for i, note_file in enumerate(note_files):
|
|
254
|
+
try:
|
|
255
|
+
note = NoteManager.load_note(note_file)
|
|
256
|
+
if note:
|
|
257
|
+
self.vector_store.add_or_update_note(note)
|
|
258
|
+
indexed += 1
|
|
259
|
+
|
|
260
|
+
if self._handler:
|
|
261
|
+
self._handler.stats.total_indexed += 1
|
|
262
|
+
|
|
263
|
+
progress.update(task, advance=1)
|
|
264
|
+
|
|
265
|
+
if progress_callback:
|
|
266
|
+
progress_callback(i + 1, total)
|
|
267
|
+
|
|
268
|
+
except Exception as e:
|
|
269
|
+
console.print(f"[red]Failed to index {note_file}: {e}[/red]")
|
|
270
|
+
|
|
271
|
+
return indexed
|
|
272
|
+
|
|
273
|
+
def index_note(self, note_id: str) -> bool:
|
|
274
|
+
"""
|
|
275
|
+
Index a specific note by ID.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
note_id: The note ID to index
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
True if successful
|
|
282
|
+
"""
|
|
283
|
+
from .note import NoteManager
|
|
284
|
+
|
|
285
|
+
note_file = NoteManager.find_note_file(self.config, note_id)
|
|
286
|
+
if not note_file:
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
note = NoteManager.load_note(note_file)
|
|
291
|
+
if note:
|
|
292
|
+
self.vector_store.add_or_update_note(note)
|
|
293
|
+
return True
|
|
294
|
+
except Exception as e:
|
|
295
|
+
console.print(f"[red]Failed to index {note_id}: {e}[/red]")
|
|
296
|
+
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
def verify_index(self) -> Dict[str, any]:
|
|
300
|
+
"""
|
|
301
|
+
Verify the index integrity.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
Dict with verification results
|
|
305
|
+
"""
|
|
306
|
+
from .note import NoteManager
|
|
307
|
+
|
|
308
|
+
notes_dir = Path(self.config.notes_dir)
|
|
309
|
+
if not notes_dir.exists():
|
|
310
|
+
return {"error": "Notes directory not found"}
|
|
311
|
+
|
|
312
|
+
# Get all note files
|
|
313
|
+
note_files = list(notes_dir.rglob("*.md"))
|
|
314
|
+
file_ids = {f.stem for f in note_files}
|
|
315
|
+
|
|
316
|
+
# Get all indexed notes
|
|
317
|
+
indexed_ids = set(self.vector_store.get_all_ids())
|
|
318
|
+
|
|
319
|
+
missing_from_index = file_ids - indexed_ids
|
|
320
|
+
orphaned_in_index = indexed_ids - file_ids
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
"total_files": len(file_ids),
|
|
324
|
+
"total_indexed": len(indexed_ids),
|
|
325
|
+
"missing_from_index": list(missing_from_index),
|
|
326
|
+
"orphaned_in_index": list(orphaned_in_index),
|
|
327
|
+
"healthy": len(missing_from_index) == 0 and len(orphaned_in_index) == 0
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class IndexerDaemon:
|
|
332
|
+
"""
|
|
333
|
+
Daemon wrapper for running indexer in background.
|
|
334
|
+
|
|
335
|
+
Usage:
|
|
336
|
+
daemon = IndexerDaemon(config, vector_store)
|
|
337
|
+
daemon.run() # Blocks until stopped
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
def __init__(self, config: ZKConfig, vector_store: VectorStore):
|
|
341
|
+
self.indexer = Indexer(config, vector_store)
|
|
342
|
+
self._stop_event = threading.Event()
|
|
343
|
+
|
|
344
|
+
def run(self):
|
|
345
|
+
"""Run the daemon until stopped."""
|
|
346
|
+
self.indexer.start(callback=self._on_event)
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
while not self._stop_event.is_set():
|
|
350
|
+
self._stop_event.wait(1)
|
|
351
|
+
except KeyboardInterrupt:
|
|
352
|
+
pass
|
|
353
|
+
finally:
|
|
354
|
+
self.indexer.stop()
|
|
355
|
+
|
|
356
|
+
def stop(self):
|
|
357
|
+
"""Signal the daemon to stop."""
|
|
358
|
+
self._stop_event.set()
|
|
359
|
+
|
|
360
|
+
def _on_event(self, event_type: str, message: str):
|
|
361
|
+
"""Handle indexer events."""
|
|
362
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
363
|
+
if event_type == "error":
|
|
364
|
+
console.print(f"[{timestamp}] [red]Error: {message}[/red]")
|
|
365
|
+
else:
|
|
366
|
+
console.print(f"[{timestamp}] [green]{event_type}: {message}[/green]")
|
jfox/kb_manager.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""
|
|
2
|
+
知识库管理器 - 高级知识库操作
|
|
3
|
+
|
|
4
|
+
提供知识库的创建、删除、统计等功能
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import shutil
|
|
8
|
+
import logging
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Optional, Dict, Any
|
|
13
|
+
|
|
14
|
+
from .global_config import (
|
|
15
|
+
GlobalConfigManager,
|
|
16
|
+
KnowledgeBaseEntry,
|
|
17
|
+
get_global_config_manager,
|
|
18
|
+
DEFAULT_KB_PATH
|
|
19
|
+
)
|
|
20
|
+
from .config import ZKConfig
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class KBStats:
|
|
28
|
+
"""知识库统计信息"""
|
|
29
|
+
name: str
|
|
30
|
+
path: Path
|
|
31
|
+
total_notes: int
|
|
32
|
+
by_type: Dict[str, int]
|
|
33
|
+
created: Optional[str] = None
|
|
34
|
+
last_used: Optional[str] = None
|
|
35
|
+
description: Optional[str] = None
|
|
36
|
+
is_current: bool = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class KnowledgeBaseManager:
|
|
40
|
+
"""
|
|
41
|
+
知识库管理器
|
|
42
|
+
|
|
43
|
+
提供知识库的完整生命周期管理:
|
|
44
|
+
- 创建、删除、重命名
|
|
45
|
+
- 统计信息
|
|
46
|
+
- 切换默认知识库
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, config_manager: Optional[GlobalConfigManager] = None):
|
|
50
|
+
self.config_manager = config_manager or get_global_config_manager()
|
|
51
|
+
|
|
52
|
+
def create(
|
|
53
|
+
self,
|
|
54
|
+
name: str,
|
|
55
|
+
path: Optional[Path] = None,
|
|
56
|
+
description: Optional[str] = None,
|
|
57
|
+
set_as_default: bool = False
|
|
58
|
+
) -> tuple[bool, str]:
|
|
59
|
+
"""
|
|
60
|
+
创建新知识库
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
name: 知识库名称
|
|
64
|
+
path: 存储路径(默认 ~/.zettelkasten/<name>/)
|
|
65
|
+
description: 描述
|
|
66
|
+
set_as_default: 是否设为默认
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
(success, message)
|
|
70
|
+
"""
|
|
71
|
+
# 检查名称是否已存在
|
|
72
|
+
if self.config_manager.kb_exists(name):
|
|
73
|
+
return False, f"Knowledge base '{name}' already exists"
|
|
74
|
+
|
|
75
|
+
# 检查名称不与保留目录冲突(default KB 的内部目录)
|
|
76
|
+
reserved_names = {"notes", ".zk", ".zk_config.json"}
|
|
77
|
+
if name.lower() in reserved_names:
|
|
78
|
+
return False, f"Name '{name}' is reserved and cannot be used as a KB name"
|
|
79
|
+
|
|
80
|
+
# 确定路径(统一管理到 ~/.zettelkasten/ 下)
|
|
81
|
+
if path is None:
|
|
82
|
+
if name == "default":
|
|
83
|
+
path = DEFAULT_KB_PATH
|
|
84
|
+
else:
|
|
85
|
+
path = DEFAULT_KB_PATH / name
|
|
86
|
+
|
|
87
|
+
path = path.expanduser().resolve()
|
|
88
|
+
|
|
89
|
+
# 校验路径必须在统一管理目录下
|
|
90
|
+
kb_root = DEFAULT_KB_PATH.resolve()
|
|
91
|
+
try:
|
|
92
|
+
path.relative_to(kb_root)
|
|
93
|
+
except ValueError:
|
|
94
|
+
return (
|
|
95
|
+
False,
|
|
96
|
+
f"Path '{path}' is outside managed directory '{kb_root}'. "
|
|
97
|
+
f"All knowledge bases must be under {kb_root}/",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# 检查路径是否已被其他知识库使用
|
|
101
|
+
for kb in self.config_manager.list_knowledge_bases():
|
|
102
|
+
if Path(kb.path) == path:
|
|
103
|
+
return False, f"Path '{path}' is already used by another knowledge base"
|
|
104
|
+
|
|
105
|
+
# 创建目录结构
|
|
106
|
+
try:
|
|
107
|
+
config = ZKConfig(base_dir=path)
|
|
108
|
+
config.ensure_dirs()
|
|
109
|
+
|
|
110
|
+
# 添加到配置
|
|
111
|
+
if not self.config_manager.add_knowledge_base(name, path, description):
|
|
112
|
+
return False, "Failed to register knowledge base"
|
|
113
|
+
|
|
114
|
+
# 设为默认(如果需要)
|
|
115
|
+
if set_as_default:
|
|
116
|
+
self.config_manager.set_default(name)
|
|
117
|
+
|
|
118
|
+
return True, f"Created knowledge base '{name}' at {path}"
|
|
119
|
+
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.error(f"Failed to create knowledge base: {e}")
|
|
122
|
+
return False, str(e)
|
|
123
|
+
|
|
124
|
+
def remove(
|
|
125
|
+
self,
|
|
126
|
+
name: str,
|
|
127
|
+
delete_data: bool = False
|
|
128
|
+
) -> tuple[bool, str]:
|
|
129
|
+
"""
|
|
130
|
+
移除知识库
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
name: 知识库名称
|
|
134
|
+
delete_data: 是否删除实际数据
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
(success, message)
|
|
138
|
+
"""
|
|
139
|
+
if not self.config_manager.kb_exists(name):
|
|
140
|
+
return False, f"Knowledge base '{name}' not found"
|
|
141
|
+
|
|
142
|
+
# 获取路径
|
|
143
|
+
path = self.config_manager.get_kb_path(name)
|
|
144
|
+
|
|
145
|
+
# 从配置中移除
|
|
146
|
+
if not self.config_manager.remove_knowledge_base(name):
|
|
147
|
+
return False, "Failed to unregister knowledge base"
|
|
148
|
+
|
|
149
|
+
# 删除数据(如果需要)
|
|
150
|
+
if delete_data and path and path.exists():
|
|
151
|
+
# 禁止删除 default KB 的数据目录,因为其他命名 KB 也在其中
|
|
152
|
+
if path.resolve() == DEFAULT_KB_PATH.resolve():
|
|
153
|
+
return (
|
|
154
|
+
True,
|
|
155
|
+
f"Removed knowledge base '{name}' from config, "
|
|
156
|
+
f"but cannot delete data: default KB directory contains other KBs",
|
|
157
|
+
)
|
|
158
|
+
try:
|
|
159
|
+
shutil.rmtree(path)
|
|
160
|
+
return True, f"Removed knowledge base '{name}' and deleted all data"
|
|
161
|
+
except Exception as e:
|
|
162
|
+
return True, f"Removed knowledge base '{name}' but failed to delete data: {e}"
|
|
163
|
+
|
|
164
|
+
return True, f"Removed knowledge base '{name}' (data preserved at {path})"
|
|
165
|
+
|
|
166
|
+
def rename(self, old_name: str, new_name: str) -> tuple[bool, str]:
|
|
167
|
+
"""
|
|
168
|
+
重命名知识库
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
old_name: 原名称
|
|
172
|
+
new_name: 新名称
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
(success, message)
|
|
176
|
+
"""
|
|
177
|
+
if not self.config_manager.kb_exists(old_name):
|
|
178
|
+
return False, f"Knowledge base '{old_name}' not found"
|
|
179
|
+
|
|
180
|
+
if self.config_manager.kb_exists(new_name):
|
|
181
|
+
return False, f"Knowledge base '{new_name}' already exists"
|
|
182
|
+
|
|
183
|
+
if self.config_manager.rename_knowledge_base(old_name, new_name):
|
|
184
|
+
return True, f"Renamed '{old_name}' to '{new_name}'"
|
|
185
|
+
|
|
186
|
+
return False, "Failed to rename knowledge base"
|
|
187
|
+
|
|
188
|
+
def switch(self, name: str) -> tuple[bool, str]:
|
|
189
|
+
"""
|
|
190
|
+
切换默认知识库
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
name: 知识库名称
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
(success, message)
|
|
197
|
+
"""
|
|
198
|
+
if not self.config_manager.kb_exists(name):
|
|
199
|
+
return False, f"Knowledge base '{name}' not found"
|
|
200
|
+
|
|
201
|
+
# 更新最后使用时间
|
|
202
|
+
self.config_manager.update_last_used(name)
|
|
203
|
+
|
|
204
|
+
if self.config_manager.set_default(name):
|
|
205
|
+
path = self.config_manager.get_kb_path(name)
|
|
206
|
+
return True, f"Switched to '{name}' ({path})"
|
|
207
|
+
|
|
208
|
+
return False, "Failed to switch knowledge base"
|
|
209
|
+
|
|
210
|
+
def list_all(self) -> List[KBStats]:
|
|
211
|
+
"""
|
|
212
|
+
列出所有知识库及其统计信息
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
知识库统计信息列表
|
|
216
|
+
"""
|
|
217
|
+
entries = self.config_manager.list_knowledge_bases()
|
|
218
|
+
current = self.config_manager.get_default_kb_name()
|
|
219
|
+
|
|
220
|
+
stats_list = []
|
|
221
|
+
for entry in entries:
|
|
222
|
+
stats = self._get_kb_stats(entry)
|
|
223
|
+
stats.is_current = (entry.name == current)
|
|
224
|
+
stats_list.append(stats)
|
|
225
|
+
|
|
226
|
+
return stats_list
|
|
227
|
+
|
|
228
|
+
def get_info(self, name: str) -> Optional[KBStats]:
|
|
229
|
+
"""
|
|
230
|
+
获取知识库详细信息
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
name: 知识库名称
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
知识库统计信息,不存在则返回 None
|
|
237
|
+
"""
|
|
238
|
+
if not self.config_manager.kb_exists(name):
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
entries = self.config_manager.list_knowledge_bases()
|
|
242
|
+
for entry in entries:
|
|
243
|
+
if entry.name == name:
|
|
244
|
+
stats = self._get_kb_stats(entry)
|
|
245
|
+
stats.is_current = (name == self.config_manager.get_default_kb_name())
|
|
246
|
+
return stats
|
|
247
|
+
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
def _get_kb_stats(self, entry: KnowledgeBaseEntry) -> KBStats:
|
|
251
|
+
"""获取知识库统计信息"""
|
|
252
|
+
path = Path(entry.path)
|
|
253
|
+
|
|
254
|
+
# 统计笔记数量
|
|
255
|
+
total = 0
|
|
256
|
+
by_type = {"fleeting": 0, "literature": 0, "permanent": 0}
|
|
257
|
+
|
|
258
|
+
if path.exists():
|
|
259
|
+
notes_dir = path / "notes"
|
|
260
|
+
if notes_dir.exists():
|
|
261
|
+
for note_type in ["fleeting", "literature", "permanent"]:
|
|
262
|
+
type_dir = notes_dir / note_type
|
|
263
|
+
if type_dir.exists():
|
|
264
|
+
count = len(list(type_dir.glob("*.md")))
|
|
265
|
+
by_type[note_type] = count
|
|
266
|
+
total += count
|
|
267
|
+
|
|
268
|
+
return KBStats(
|
|
269
|
+
name=entry.name,
|
|
270
|
+
path=path,
|
|
271
|
+
total_notes=total,
|
|
272
|
+
by_type=by_type,
|
|
273
|
+
created=entry.created,
|
|
274
|
+
last_used=entry.last_used,
|
|
275
|
+
description=entry.description,
|
|
276
|
+
is_current=False,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def get_current_kb_info(self) -> Optional[KBStats]:
|
|
280
|
+
"""获取当前默认知识库信息"""
|
|
281
|
+
current_name = self.config_manager.get_default_kb_name()
|
|
282
|
+
return self.get_info(current_name)
|
|
283
|
+
|
|
284
|
+
def ensure_default_exists(self) -> bool:
|
|
285
|
+
"""
|
|
286
|
+
确保默认知识库存在,如果不存在则创建
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
是否成功
|
|
290
|
+
"""
|
|
291
|
+
default_name = self.config_manager.get_default_kb_name()
|
|
292
|
+
|
|
293
|
+
if not self.config_manager.kb_exists(default_name):
|
|
294
|
+
# 创建默认知识库
|
|
295
|
+
path = self.config_manager.get_default_kb_path()
|
|
296
|
+
success, _ = self.create(
|
|
297
|
+
name=default_name,
|
|
298
|
+
path=path,
|
|
299
|
+
description="Default knowledge base",
|
|
300
|
+
set_as_default=True
|
|
301
|
+
)
|
|
302
|
+
return success
|
|
303
|
+
|
|
304
|
+
return True
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# 全局知识库管理器实例
|
|
308
|
+
_kb_manager: Optional[KnowledgeBaseManager] = None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def get_kb_manager() -> KnowledgeBaseManager:
|
|
312
|
+
"""获取知识库管理器实例"""
|
|
313
|
+
global _kb_manager
|
|
314
|
+
if _kb_manager is None:
|
|
315
|
+
_kb_manager = KnowledgeBaseManager()
|
|
316
|
+
return _kb_manager
|