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/cli.py
ADDED
|
@@ -0,0 +1,1895 @@
|
|
|
1
|
+
"""CLI 主程序"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import warnings
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, List
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
|
|
10
|
+
# 过滤 networkx 的 backend 警告
|
|
11
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning, message="networkx backend defined more than once")
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.tree import Tree
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
|
|
19
|
+
from .models import NoteType
|
|
20
|
+
from .config import config, ZKConfig, get_config
|
|
21
|
+
from . import note
|
|
22
|
+
from .embedding_backend import get_backend
|
|
23
|
+
from .graph import KnowledgeGraph
|
|
24
|
+
from .indexer import Indexer
|
|
25
|
+
from .vector_store import get_vector_store
|
|
26
|
+
from .kb_manager import get_kb_manager, KBStats
|
|
27
|
+
from .performance import (
|
|
28
|
+
bulk_import_notes,
|
|
29
|
+
BatchProcessor,
|
|
30
|
+
ModelCache,
|
|
31
|
+
get_perf_monitor
|
|
32
|
+
)
|
|
33
|
+
from .template import TemplateManager, TemplateNotFoundError, TemplateRenderError
|
|
34
|
+
from .template_cli import template_app
|
|
35
|
+
|
|
36
|
+
# 配置日志
|
|
37
|
+
logging.basicConfig(
|
|
38
|
+
level=logging.INFO,
|
|
39
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
40
|
+
)
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# 创建应用
|
|
44
|
+
app = typer.Typer(
|
|
45
|
+
name="jfox",
|
|
46
|
+
help="JFox - Zettelkasten 知识管理 CLI",
|
|
47
|
+
add_completion=False,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# 添加子命令
|
|
51
|
+
app.add_typer(template_app, name="template", help="Manage note templates")
|
|
52
|
+
|
|
53
|
+
console = Console(legacy_windows=False)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def output_json(data: dict) -> str:
|
|
57
|
+
"""输出 JSON 格式"""
|
|
58
|
+
return json.dumps(data, ensure_ascii=False, indent=2)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def init(
|
|
63
|
+
name: Optional[str] = typer.Option(None, "--name", "-n", help="知识库名称(默认: default)"),
|
|
64
|
+
path: Optional[str] = typer.Option(None, "--path", "-p", help="知识库路径(默认: ~/.zettelkasten/<name>/)"),
|
|
65
|
+
description: Optional[str] = typer.Option(None, "--desc", "-d", help="知识库描述"),
|
|
66
|
+
set_default: bool = typer.Option(True, "--default/--no-default", help="设为默认知识库"),
|
|
67
|
+
json_output: bool = typer.Option(True, "--json/--no-json", help="JSON 输出"),
|
|
68
|
+
):
|
|
69
|
+
"""
|
|
70
|
+
初始化知识库
|
|
71
|
+
|
|
72
|
+
创建一个新的知识库并注册到全局配置。
|
|
73
|
+
|
|
74
|
+
示例:
|
|
75
|
+
jfox init # 初始化默认知识库
|
|
76
|
+
jfox init --name work # 创建名为 work 的知识库(~/.zettelkasten/work/)
|
|
77
|
+
jfox init --name personal --desc "个人笔记"
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
kb_name = name or "default"
|
|
81
|
+
manager = get_kb_manager()
|
|
82
|
+
|
|
83
|
+
# 如果知识库已存在,提示错误
|
|
84
|
+
if manager.config_manager.kb_exists(kb_name):
|
|
85
|
+
result = {
|
|
86
|
+
"success": False,
|
|
87
|
+
"error": f"Knowledge base '{kb_name}' already exists. Use 'jfox kb list' to see all knowledge bases.",
|
|
88
|
+
}
|
|
89
|
+
if json_output:
|
|
90
|
+
print(output_json(result))
|
|
91
|
+
else:
|
|
92
|
+
console.print(f"[red]✗[/red] Knowledge base '{kb_name}' already exists")
|
|
93
|
+
console.print(f"[dim]Use 'jfox kb list' to see all knowledge bases[/dim]")
|
|
94
|
+
raise typer.Exit(1)
|
|
95
|
+
|
|
96
|
+
# 确定路径
|
|
97
|
+
path_obj = Path(path) if path else None
|
|
98
|
+
|
|
99
|
+
# 用户显式指定路径时,验证必须在管理目录下
|
|
100
|
+
if path_obj is not None:
|
|
101
|
+
from jfox.global_config import DEFAULT_KB_PATH
|
|
102
|
+
resolved = path_obj.expanduser().resolve()
|
|
103
|
+
kb_root = DEFAULT_KB_PATH.resolve()
|
|
104
|
+
try:
|
|
105
|
+
resolved.relative_to(kb_root)
|
|
106
|
+
except ValueError:
|
|
107
|
+
result = {
|
|
108
|
+
"success": False,
|
|
109
|
+
"error": (
|
|
110
|
+
f"Path '{resolved}' is outside managed directory "
|
|
111
|
+
f"'{kb_root}'. All knowledge bases must be under {kb_root}/"
|
|
112
|
+
),
|
|
113
|
+
}
|
|
114
|
+
if json_output:
|
|
115
|
+
print(output_json(result))
|
|
116
|
+
else:
|
|
117
|
+
console.print(f"[red]✗[/red] {result['error']}")
|
|
118
|
+
raise typer.Exit(1)
|
|
119
|
+
|
|
120
|
+
# 创建知识库
|
|
121
|
+
success, message = manager.create(
|
|
122
|
+
name=kb_name,
|
|
123
|
+
path=path_obj,
|
|
124
|
+
description=description,
|
|
125
|
+
set_as_default=set_default
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if success:
|
|
129
|
+
result = {
|
|
130
|
+
"success": True,
|
|
131
|
+
"message": message,
|
|
132
|
+
"name": kb_name,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if json_output:
|
|
136
|
+
print(output_json(result))
|
|
137
|
+
else:
|
|
138
|
+
console.print(f"[green]✓[/green] {message}")
|
|
139
|
+
if set_default:
|
|
140
|
+
console.print(f"[dim]This is now your default knowledge base[/dim]")
|
|
141
|
+
else:
|
|
142
|
+
result = {
|
|
143
|
+
"success": False,
|
|
144
|
+
"error": message,
|
|
145
|
+
}
|
|
146
|
+
if json_output:
|
|
147
|
+
print(output_json(result))
|
|
148
|
+
else:
|
|
149
|
+
console.print(f"[red]✗[/red] {message}")
|
|
150
|
+
raise typer.Exit(1)
|
|
151
|
+
|
|
152
|
+
except Exception as e:
|
|
153
|
+
result = {
|
|
154
|
+
"success": False,
|
|
155
|
+
"error": str(e),
|
|
156
|
+
}
|
|
157
|
+
if json_output:
|
|
158
|
+
print(output_json(result))
|
|
159
|
+
else:
|
|
160
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
161
|
+
raise typer.Exit(1)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def extract_wiki_links(content: str) -> List[str]:
|
|
165
|
+
"""从内容中提取 [[...]] 格式的维基链接"""
|
|
166
|
+
import re
|
|
167
|
+
pattern = r'\[\[(.*?)\]\]'
|
|
168
|
+
matches = re.findall(pattern, content)
|
|
169
|
+
return [m.strip() for m in matches]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def find_note_id_by_title_or_id(
|
|
173
|
+
title_or_id: str, all_notes: Optional[list] = None
|
|
174
|
+
) -> Optional[str]:
|
|
175
|
+
"""通过标题或ID查找笔记
|
|
176
|
+
|
|
177
|
+
匹配优先级:精确ID → 精确标题 → 标题包含
|
|
178
|
+
"""
|
|
179
|
+
if all_notes is None:
|
|
180
|
+
all_notes = note.list_notes()
|
|
181
|
+
|
|
182
|
+
# 单次遍历,按优先级:精确ID → 精确标题 → 标题包含
|
|
183
|
+
title_lower = title_or_id.lower()
|
|
184
|
+
contains_match = None
|
|
185
|
+
|
|
186
|
+
for n in all_notes:
|
|
187
|
+
if n.id == title_or_id:
|
|
188
|
+
return n.id
|
|
189
|
+
if n.title.lower() == title_lower:
|
|
190
|
+
return n.id
|
|
191
|
+
if contains_match is None and title_lower in n.title.lower():
|
|
192
|
+
contains_match = n.id
|
|
193
|
+
|
|
194
|
+
return contains_match
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _add_note_impl(
|
|
198
|
+
content: str,
|
|
199
|
+
title: Optional[str],
|
|
200
|
+
note_type: str,
|
|
201
|
+
tags: Optional[List[str]],
|
|
202
|
+
source: Optional[str],
|
|
203
|
+
json_output: bool,
|
|
204
|
+
template: Optional[str] = None,
|
|
205
|
+
):
|
|
206
|
+
"""添加笔记的内部实现"""
|
|
207
|
+
# 如果指定了模板,使用模板渲染
|
|
208
|
+
if template:
|
|
209
|
+
templates_dir = config.base_dir / ".zk" / "templates"
|
|
210
|
+
template_manager = TemplateManager(templates_dir)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
# 准备模板变量
|
|
214
|
+
template_vars = {
|
|
215
|
+
"title": title or "",
|
|
216
|
+
"content": content,
|
|
217
|
+
"source": source or "",
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# 渲染模板
|
|
221
|
+
rendered = template_manager.render(template, template_vars)
|
|
222
|
+
|
|
223
|
+
# 使用渲染结果
|
|
224
|
+
content = rendered["content"]
|
|
225
|
+
if rendered["title"]:
|
|
226
|
+
title = rendered["title"]
|
|
227
|
+
note_type = rendered["note_type"]
|
|
228
|
+
# 合并模板的 tags 和用户提供的 tags
|
|
229
|
+
template_tags = rendered["tags"]
|
|
230
|
+
if tags:
|
|
231
|
+
tags = list(set(template_tags + tags))
|
|
232
|
+
else:
|
|
233
|
+
tags = template_tags
|
|
234
|
+
|
|
235
|
+
except TemplateNotFoundError as e:
|
|
236
|
+
raise ValueError(str(e))
|
|
237
|
+
except TemplateRenderError as e:
|
|
238
|
+
raise ValueError(str(e))
|
|
239
|
+
|
|
240
|
+
# 解析类型
|
|
241
|
+
try:
|
|
242
|
+
nt = NoteType(note_type.lower())
|
|
243
|
+
except ValueError:
|
|
244
|
+
raise ValueError(f"Invalid note type: {note_type}. Use: fleeting, literature, permanent")
|
|
245
|
+
|
|
246
|
+
# 从内容中提取维基链接
|
|
247
|
+
wiki_links = extract_wiki_links(content)
|
|
248
|
+
resolved_links = []
|
|
249
|
+
unresolved = []
|
|
250
|
+
|
|
251
|
+
# 缓存笔记列表,避免每个链接重复加载
|
|
252
|
+
all_notes = note.list_notes() if wiki_links else []
|
|
253
|
+
|
|
254
|
+
for link_text in wiki_links:
|
|
255
|
+
target_id = find_note_id_by_title_or_id(link_text, all_notes=all_notes)
|
|
256
|
+
if target_id:
|
|
257
|
+
resolved_links.append(target_id)
|
|
258
|
+
else:
|
|
259
|
+
unresolved.append(link_text)
|
|
260
|
+
|
|
261
|
+
# 创建笔记
|
|
262
|
+
new_note = note.create_note(
|
|
263
|
+
content=content,
|
|
264
|
+
title=title,
|
|
265
|
+
note_type=nt,
|
|
266
|
+
tags=tags or [],
|
|
267
|
+
links=resolved_links,
|
|
268
|
+
source=source,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# 保存笔记
|
|
272
|
+
if note.save_note(new_note):
|
|
273
|
+
# 更新被链接笔记的反向链接
|
|
274
|
+
backlink_updated = 0
|
|
275
|
+
for target_id in resolved_links:
|
|
276
|
+
target_note = note.load_note_by_id(target_id)
|
|
277
|
+
if target_note:
|
|
278
|
+
# 避免重复添加
|
|
279
|
+
if new_note.id not in target_note.backlinks:
|
|
280
|
+
target_note.backlinks.append(new_note.id)
|
|
281
|
+
# 重新保存目标笔记
|
|
282
|
+
note.save_note(target_note, add_to_index=False)
|
|
283
|
+
backlink_updated += 1
|
|
284
|
+
|
|
285
|
+
result = {
|
|
286
|
+
"success": True,
|
|
287
|
+
"note": {
|
|
288
|
+
"id": new_note.id,
|
|
289
|
+
"title": new_note.title,
|
|
290
|
+
"type": new_note.type.value,
|
|
291
|
+
"filepath": str(new_note.filepath),
|
|
292
|
+
"links": resolved_links,
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if unresolved:
|
|
297
|
+
result["warnings"] = f"Unresolved links: {', '.join(unresolved)}"
|
|
298
|
+
|
|
299
|
+
if json_output:
|
|
300
|
+
print(output_json(result))
|
|
301
|
+
else:
|
|
302
|
+
console.print(f"[green]✓[/green] Note created: {new_note.title}")
|
|
303
|
+
console.print(f" ID: {new_note.id}")
|
|
304
|
+
console.print(f" Path: {new_note.filepath}")
|
|
305
|
+
if resolved_links:
|
|
306
|
+
console.print(f" Links: {len(resolved_links)} connection(s)")
|
|
307
|
+
if backlink_updated > 0:
|
|
308
|
+
console.print(f" Backlinks updated: {backlink_updated} note(s)")
|
|
309
|
+
if unresolved:
|
|
310
|
+
console.print(f" [yellow]Warning: Unresolved links - {', '.join(unresolved)}[/yellow]")
|
|
311
|
+
else:
|
|
312
|
+
raise Exception("Failed to save note")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@app.command()
|
|
316
|
+
def add(
|
|
317
|
+
content: str = typer.Argument(..., help="笔记内容(支持 [[笔记标题]] 格式链接)"),
|
|
318
|
+
title: Optional[str] = typer.Option(None, "--title", "-t", help="笔记标题"),
|
|
319
|
+
note_type: str = typer.Option("fleeting", "--type", help="笔记类型 (fleeting/literature/permanent)"),
|
|
320
|
+
tags: Optional[List[str]] = typer.Option(None, "--tag", help="标签(可多次使用)"),
|
|
321
|
+
source: Optional[str] = typer.Option(None, "--source", "-s", help="来源(文献笔记)"),
|
|
322
|
+
template: Optional[str] = typer.Option(None, "--template", "-T", help="使用模板创建笔记 (quick/meeting/literature)"),
|
|
323
|
+
kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
|
|
324
|
+
json_output: bool = typer.Option(True, "--json/--no-json", help="JSON 输出"),
|
|
325
|
+
):
|
|
326
|
+
"""添加新笔记(内容中可用 [[笔记标题]] 引用其他笔记)"""
|
|
327
|
+
try:
|
|
328
|
+
# 如果指定了知识库,临时切换
|
|
329
|
+
if kb:
|
|
330
|
+
from .config import use_kb
|
|
331
|
+
with use_kb(kb):
|
|
332
|
+
_add_note_impl(content, title, note_type, tags, source, json_output, template)
|
|
333
|
+
else:
|
|
334
|
+
_add_note_impl(content, title, note_type, tags, source, json_output, template)
|
|
335
|
+
|
|
336
|
+
except Exception as e:
|
|
337
|
+
result = {
|
|
338
|
+
"success": False,
|
|
339
|
+
"error": str(e),
|
|
340
|
+
}
|
|
341
|
+
if json_output:
|
|
342
|
+
print(output_json(result))
|
|
343
|
+
else:
|
|
344
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
345
|
+
raise typer.Exit(1)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _search_impl(
|
|
349
|
+
query: str,
|
|
350
|
+
top: int,
|
|
351
|
+
note_type: Optional[str],
|
|
352
|
+
search_mode: str,
|
|
353
|
+
output_format: str,
|
|
354
|
+
):
|
|
355
|
+
"""搜索笔记的内部实现"""
|
|
356
|
+
from .formatters import OutputFormatter
|
|
357
|
+
|
|
358
|
+
results = note.search_notes(query, top_k=top, note_type=note_type, mode=search_mode)
|
|
359
|
+
|
|
360
|
+
result = {
|
|
361
|
+
"query": query,
|
|
362
|
+
"mode": search_mode,
|
|
363
|
+
"total": len(results),
|
|
364
|
+
"results": results,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if output_format == "json":
|
|
368
|
+
print(OutputFormatter.to_json(result))
|
|
369
|
+
elif output_format == "table":
|
|
370
|
+
mode_display = {
|
|
371
|
+
"hybrid": "Hybrid (BM25 + Semantic)",
|
|
372
|
+
"semantic": "Semantic",
|
|
373
|
+
"keyword": "Keyword (BM25)",
|
|
374
|
+
}.get(search_mode, search_mode)
|
|
375
|
+
|
|
376
|
+
console.print(f"[bold]Query:[/bold] {query}")
|
|
377
|
+
console.print(f"[bold]Mode:[/bold] {mode_display}")
|
|
378
|
+
console.print(f"[bold]Results:[/bold] {len(results)}\n")
|
|
379
|
+
|
|
380
|
+
for i, r in enumerate(results, 1):
|
|
381
|
+
score = r.get("score", 0)
|
|
382
|
+
mode_badge = r.get("search_mode", "")
|
|
383
|
+
badge = ""
|
|
384
|
+
if mode_badge == "semantic":
|
|
385
|
+
badge = " [cyan]S[/cyan]"
|
|
386
|
+
elif mode_badge == "keyword":
|
|
387
|
+
badge = " [yellow]K[/yellow]"
|
|
388
|
+
|
|
389
|
+
console.print(f"{i}. [{score:.2f}]{badge} {r['metadata'].get('title', 'Untitled')}")
|
|
390
|
+
console.print(f" {r['document'][:100]}...")
|
|
391
|
+
console.print()
|
|
392
|
+
elif output_format == "csv":
|
|
393
|
+
# 扁平化结果数据
|
|
394
|
+
flat_results = []
|
|
395
|
+
for r in results:
|
|
396
|
+
flat_r = {
|
|
397
|
+
"id": r.get("id", ""),
|
|
398
|
+
"title": r.get("metadata", {}).get("title", ""),
|
|
399
|
+
"type": r.get("metadata", {}).get("type", ""),
|
|
400
|
+
"score": r.get("score", 0),
|
|
401
|
+
"search_mode": r.get("search_mode", ""),
|
|
402
|
+
}
|
|
403
|
+
flat_results.append(flat_r)
|
|
404
|
+
console.print(OutputFormatter.to_csv(flat_results, headers=["id", "title", "type", "score", "search_mode"]))
|
|
405
|
+
elif output_format == "yaml":
|
|
406
|
+
print(OutputFormatter.to_yaml(result))
|
|
407
|
+
elif output_format == "paths":
|
|
408
|
+
# 从结果中提取文件路径
|
|
409
|
+
from . import note as note_module
|
|
410
|
+
paths = []
|
|
411
|
+
for r in results:
|
|
412
|
+
note_id = r.get("id")
|
|
413
|
+
if note_id:
|
|
414
|
+
n = note_module.load_note_by_id(note_id)
|
|
415
|
+
if n and n.filepath:
|
|
416
|
+
paths.append({"filepath": n.filepath})
|
|
417
|
+
console.print(OutputFormatter.to_paths(paths, key="filepath"))
|
|
418
|
+
else:
|
|
419
|
+
raise ValueError(f"Unsupported format: {output_format}")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@app.command()
|
|
423
|
+
def search(
|
|
424
|
+
query: str = typer.Argument(..., help="搜索查询"),
|
|
425
|
+
top: int = typer.Option(5, "--top", "-n", help="返回结果数量"),
|
|
426
|
+
note_type: Optional[str] = typer.Option(None, "--type", "-t", help="筛选笔记类型"),
|
|
427
|
+
search_mode: str = typer.Option("hybrid", "--mode", "-m", help="搜索模式: hybrid, semantic, keyword"),
|
|
428
|
+
output_format: str = typer.Option("table", "--format", "-f",
|
|
429
|
+
help="输出格式: json, table, csv, yaml, paths"),
|
|
430
|
+
json_output: bool = typer.Option(False, "--json/--no-json", help="JSON 输出(向后兼容)"),
|
|
431
|
+
kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
|
|
432
|
+
):
|
|
433
|
+
"""
|
|
434
|
+
搜索笔记
|
|
435
|
+
|
|
436
|
+
支持三种搜索模式:
|
|
437
|
+
- hybrid: 混合搜索(BM25 + 语义),默认
|
|
438
|
+
- semantic: 纯语义搜索
|
|
439
|
+
- keyword: 纯关键词搜索 (BM25)
|
|
440
|
+
|
|
441
|
+
示例:
|
|
442
|
+
jfox search "Python" --mode hybrid
|
|
443
|
+
jfox search "async await" --mode keyword --top 10
|
|
444
|
+
"""
|
|
445
|
+
try:
|
|
446
|
+
# 向后兼容:如果指定了 --json,使用 json 格式
|
|
447
|
+
if json_output:
|
|
448
|
+
output_format = "json"
|
|
449
|
+
|
|
450
|
+
# 如果指定了知识库,临时切换
|
|
451
|
+
if kb:
|
|
452
|
+
from .config import use_kb
|
|
453
|
+
with use_kb(kb):
|
|
454
|
+
_search_impl(query, top, note_type, search_mode, output_format)
|
|
455
|
+
else:
|
|
456
|
+
_search_impl(query, top, note_type, search_mode, output_format)
|
|
457
|
+
|
|
458
|
+
except Exception as e:
|
|
459
|
+
result = {
|
|
460
|
+
"success": False,
|
|
461
|
+
"error": str(e),
|
|
462
|
+
}
|
|
463
|
+
if output_format == "json":
|
|
464
|
+
print(output_json(result))
|
|
465
|
+
else:
|
|
466
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
467
|
+
raise typer.Exit(1)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _status_impl(output_format: str, json_output: bool):
|
|
471
|
+
"""查看知识库状态的内部实现"""
|
|
472
|
+
from .formatters import OutputFormatter
|
|
473
|
+
|
|
474
|
+
stats = note.get_stats()
|
|
475
|
+
|
|
476
|
+
# 获取 NPU 状态
|
|
477
|
+
backend = get_backend()
|
|
478
|
+
|
|
479
|
+
result = {
|
|
480
|
+
"knowledge_base": {
|
|
481
|
+
"path": str(config.base_dir),
|
|
482
|
+
"exists": config.base_dir.exists(),
|
|
483
|
+
},
|
|
484
|
+
"stats": stats,
|
|
485
|
+
"backend": {
|
|
486
|
+
"type": "CPU",
|
|
487
|
+
"model": backend.model_name if backend.model else "not loaded",
|
|
488
|
+
},
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
# 处理 --json 快捷方式
|
|
492
|
+
if json_output:
|
|
493
|
+
output_format = "json"
|
|
494
|
+
|
|
495
|
+
# 根据格式输出
|
|
496
|
+
if output_format == "json":
|
|
497
|
+
print(OutputFormatter.to_json(result))
|
|
498
|
+
elif output_format == "yaml":
|
|
499
|
+
print(OutputFormatter.to_yaml(result))
|
|
500
|
+
elif output_format == "table":
|
|
501
|
+
# 打印表格
|
|
502
|
+
table = Table(title="Knowledge Base Status")
|
|
503
|
+
table.add_column("Property", style="cyan")
|
|
504
|
+
table.add_column("Value", style="green")
|
|
505
|
+
|
|
506
|
+
table.add_row("Base Path", str(config.base_dir))
|
|
507
|
+
table.add_row("Total Notes", str(stats["total"]))
|
|
508
|
+
table.add_row("Fleeting", str(stats["by_type"].get("fleeting", 0)))
|
|
509
|
+
table.add_row("Literature", str(stats["by_type"].get("literature", 0)))
|
|
510
|
+
table.add_row("Permanent", str(stats["by_type"].get("permanent", 0)))
|
|
511
|
+
table.add_row("Backend", "CPU")
|
|
512
|
+
table.add_row("Model", backend.model_name)
|
|
513
|
+
|
|
514
|
+
console.print(table)
|
|
515
|
+
else:
|
|
516
|
+
console.print(f"[red]Error:[/red] Unsupported format: {output_format}")
|
|
517
|
+
raise typer.Exit(1)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@app.command()
|
|
521
|
+
def status(
|
|
522
|
+
output_format: str = typer.Option("table", "--format", "-f", help="输出格式: json, table, yaml"),
|
|
523
|
+
json_output: bool = typer.Option(False, "--json", help="JSON 输出(快捷方式,等同于 --format json)"),
|
|
524
|
+
kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
|
|
525
|
+
):
|
|
526
|
+
"""查看知识库状态"""
|
|
527
|
+
try:
|
|
528
|
+
# 如果指定了知识库,临时切换
|
|
529
|
+
if kb:
|
|
530
|
+
from .config import use_kb
|
|
531
|
+
with use_kb(kb):
|
|
532
|
+
_status_impl(output_format, json_output)
|
|
533
|
+
else:
|
|
534
|
+
_status_impl(output_format, json_output)
|
|
535
|
+
|
|
536
|
+
except Exception as e:
|
|
537
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
538
|
+
raise typer.Exit(1)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _list_impl(
|
|
542
|
+
note_type: Optional[str],
|
|
543
|
+
limit: int,
|
|
544
|
+
output_format: str,
|
|
545
|
+
):
|
|
546
|
+
"""列出笔记的内部实现"""
|
|
547
|
+
from .formatters import OutputFormatter
|
|
548
|
+
|
|
549
|
+
# 解析类型
|
|
550
|
+
nt = None
|
|
551
|
+
if note_type:
|
|
552
|
+
try:
|
|
553
|
+
nt = NoteType(note_type.lower())
|
|
554
|
+
except ValueError:
|
|
555
|
+
raise ValueError(f"Invalid note type: {note_type}")
|
|
556
|
+
|
|
557
|
+
notes = note.list_notes(note_type=nt, limit=limit)
|
|
558
|
+
data = [n.to_dict() for n in notes]
|
|
559
|
+
|
|
560
|
+
result = {
|
|
561
|
+
"total": len(notes),
|
|
562
|
+
"notes": data,
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if output_format == "json":
|
|
566
|
+
print(OutputFormatter.to_json(result))
|
|
567
|
+
elif output_format == "table":
|
|
568
|
+
table = Table(title=f"Notes ({len(notes)} total)")
|
|
569
|
+
table.add_column("ID", style="dim")
|
|
570
|
+
table.add_column("Title", style="cyan")
|
|
571
|
+
table.add_column("Type", style="green")
|
|
572
|
+
table.add_column("Created", style="dim")
|
|
573
|
+
|
|
574
|
+
for n in notes:
|
|
575
|
+
created_str = n.created.strftime("%Y-%m-%d") if n.created else ""
|
|
576
|
+
table.add_row(n.id[:14], n.title[:40], n.type.value, created_str)
|
|
577
|
+
|
|
578
|
+
console.print(table)
|
|
579
|
+
elif output_format == "tree":
|
|
580
|
+
console.print(OutputFormatter.to_tree(data, group_by="type"))
|
|
581
|
+
elif output_format in ["csv", "yaml", "paths"]:
|
|
582
|
+
# 对于 csv, yaml, paths,只输出 notes 列表
|
|
583
|
+
if output_format == "csv":
|
|
584
|
+
console.print(OutputFormatter.to_csv(data, headers=["id", "title", "type", "created"]))
|
|
585
|
+
elif output_format == "yaml":
|
|
586
|
+
print(OutputFormatter.to_yaml(result))
|
|
587
|
+
elif output_format == "paths":
|
|
588
|
+
console.print(OutputFormatter.to_paths(data, key="filepath"))
|
|
589
|
+
else:
|
|
590
|
+
raise ValueError(f"Unsupported format: {output_format}")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@app.command()
|
|
594
|
+
def list(
|
|
595
|
+
note_type: Optional[str] = typer.Option(None, "--type", "-t", help="筛选笔记类型"),
|
|
596
|
+
limit: int = typer.Option(10, "--limit", "-n", help="显示数量"),
|
|
597
|
+
output_format: str = typer.Option("table", "--format", "-f",
|
|
598
|
+
help="输出格式: json, table, csv, yaml, paths, tree"),
|
|
599
|
+
json_output: bool = typer.Option(False, "--json/--no-json", help="JSON 输出(向后兼容)"),
|
|
600
|
+
kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
|
|
601
|
+
):
|
|
602
|
+
"""
|
|
603
|
+
列出笔记
|
|
604
|
+
|
|
605
|
+
支持多种输出格式:
|
|
606
|
+
- json: JSON 格式
|
|
607
|
+
- table: 表格格式(默认)
|
|
608
|
+
- csv: CSV 格式,可用于 Excel
|
|
609
|
+
- yaml: YAML 格式
|
|
610
|
+
- paths: 仅输出文件路径
|
|
611
|
+
- tree: 树形结构
|
|
612
|
+
"""
|
|
613
|
+
try:
|
|
614
|
+
# 向后兼容:如果指定了 --json,使用 json 格式
|
|
615
|
+
if json_output:
|
|
616
|
+
output_format = "json"
|
|
617
|
+
|
|
618
|
+
# 如果指定了知识库,临时切换
|
|
619
|
+
if kb:
|
|
620
|
+
from .config import use_kb
|
|
621
|
+
with use_kb(kb):
|
|
622
|
+
_list_impl(note_type, limit, output_format)
|
|
623
|
+
else:
|
|
624
|
+
_list_impl(note_type, limit, output_format)
|
|
625
|
+
|
|
626
|
+
except Exception as e:
|
|
627
|
+
result = {
|
|
628
|
+
"success": False,
|
|
629
|
+
"error": str(e),
|
|
630
|
+
}
|
|
631
|
+
if output_format == "json":
|
|
632
|
+
print(output_json(result))
|
|
633
|
+
else:
|
|
634
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
635
|
+
raise typer.Exit(1)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _refs_impl(
|
|
639
|
+
note_id: Optional[str],
|
|
640
|
+
search: Optional[str],
|
|
641
|
+
output_format: str,
|
|
642
|
+
json_output: bool,
|
|
643
|
+
):
|
|
644
|
+
"""查看笔记引用关系的内部实现"""
|
|
645
|
+
if search:
|
|
646
|
+
# 搜索笔记
|
|
647
|
+
all_notes = note.list_notes()
|
|
648
|
+
matches = [n for n in all_notes if search.lower() in n.title.lower()]
|
|
649
|
+
|
|
650
|
+
result = {
|
|
651
|
+
"query": search,
|
|
652
|
+
"matches": [
|
|
653
|
+
{"id": n.id, "title": n.title, "type": n.type.value}
|
|
654
|
+
for n in matches
|
|
655
|
+
]
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if output_format == "json":
|
|
659
|
+
print(output_json(result))
|
|
660
|
+
else:
|
|
661
|
+
console.print(f"[bold]Search:[/bold] '{search}'\n")
|
|
662
|
+
if matches:
|
|
663
|
+
for n in matches:
|
|
664
|
+
console.print(f"- [{n.type.value}] {n.title}")
|
|
665
|
+
console.print(f" ID: {n.id}")
|
|
666
|
+
console.print(f" 引用此笔记: {len(n.backlinks)} 处")
|
|
667
|
+
console.print()
|
|
668
|
+
else:
|
|
669
|
+
console.print("[dim]No matches found[/dim]")
|
|
670
|
+
|
|
671
|
+
elif note_id:
|
|
672
|
+
# 查看特定笔记的引用关系
|
|
673
|
+
n = note.load_note_by_id(note_id)
|
|
674
|
+
if not n:
|
|
675
|
+
console.print(f"[red]Note not found: {note_id}[/red]")
|
|
676
|
+
raise typer.Exit(1)
|
|
677
|
+
|
|
678
|
+
# 获取链接到的笔记
|
|
679
|
+
forward_links = []
|
|
680
|
+
for link_id in n.links:
|
|
681
|
+
link_note = note.load_note_by_id(link_id)
|
|
682
|
+
if link_note:
|
|
683
|
+
forward_links.append({
|
|
684
|
+
"id": link_id,
|
|
685
|
+
"title": link_note.title,
|
|
686
|
+
"type": link_note.type.value
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
# 获取反向链接
|
|
690
|
+
backward_links = []
|
|
691
|
+
for back_id in n.backlinks:
|
|
692
|
+
back_note = note.load_note_by_id(back_id)
|
|
693
|
+
if back_note:
|
|
694
|
+
backward_links.append({
|
|
695
|
+
"id": back_id,
|
|
696
|
+
"title": back_note.title,
|
|
697
|
+
"type": back_note.type.value
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
result = {
|
|
701
|
+
"note": {
|
|
702
|
+
"id": n.id,
|
|
703
|
+
"title": n.title,
|
|
704
|
+
"type": n.type.value,
|
|
705
|
+
},
|
|
706
|
+
"forward_links": forward_links,
|
|
707
|
+
"backward_links": backward_links,
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if output_format == "json":
|
|
711
|
+
print(output_json(result))
|
|
712
|
+
else:
|
|
713
|
+
console.print(f"[bold]{n.title}[/bold]\n")
|
|
714
|
+
|
|
715
|
+
if forward_links:
|
|
716
|
+
console.print("[cyan]→ Links to:[/cyan]")
|
|
717
|
+
for link in forward_links:
|
|
718
|
+
console.print(f" - [{link['type']}] {link['title']}")
|
|
719
|
+
console.print()
|
|
720
|
+
|
|
721
|
+
if backward_links:
|
|
722
|
+
console.print("[green]← Linked by:[/green]")
|
|
723
|
+
for link in backward_links:
|
|
724
|
+
console.print(f" - [{link['type']}] {link['title']}")
|
|
725
|
+
console.print()
|
|
726
|
+
|
|
727
|
+
if not forward_links and not backward_links:
|
|
728
|
+
console.print("[dim]No connections yet[/dim]")
|
|
729
|
+
|
|
730
|
+
else:
|
|
731
|
+
# 显示所有笔记及其链接统计
|
|
732
|
+
all_notes = note.list_notes()
|
|
733
|
+
notes_with_links = []
|
|
734
|
+
for n in all_notes:
|
|
735
|
+
notes_with_links.append({
|
|
736
|
+
"id": n.id,
|
|
737
|
+
"title": n.title,
|
|
738
|
+
"type": n.type.value,
|
|
739
|
+
"outgoing": len(n.links),
|
|
740
|
+
"incoming": len(n.backlinks),
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
result = {"notes": notes_with_links}
|
|
744
|
+
|
|
745
|
+
if output_format == "json":
|
|
746
|
+
print(output_json(result))
|
|
747
|
+
else:
|
|
748
|
+
table = Table(title="Note References")
|
|
749
|
+
table.add_column("ID", style="dim")
|
|
750
|
+
table.add_column("Title", style="cyan")
|
|
751
|
+
table.add_column("Type", style="green")
|
|
752
|
+
table.add_column("Out", justify="right")
|
|
753
|
+
table.add_column("In", justify="right")
|
|
754
|
+
|
|
755
|
+
for n in notes_with_links:
|
|
756
|
+
table.add_row(
|
|
757
|
+
n["id"][:14],
|
|
758
|
+
n["title"][:40],
|
|
759
|
+
n["type"],
|
|
760
|
+
str(n["outgoing"]),
|
|
761
|
+
str(n["incoming"])
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
console.print(table)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
@app.command()
|
|
768
|
+
def refs(
|
|
769
|
+
note_id: Optional[str] = typer.Option(None, "--note", "-n", help="查看特定笔记的引用关系"),
|
|
770
|
+
search: Optional[str] = typer.Option(None, "--search", "-s", help="搜索笔记标题"),
|
|
771
|
+
kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
|
|
772
|
+
output_format: str = typer.Option("table", "--format", "-f", help="输出格式: json, table"),
|
|
773
|
+
json_output: bool = typer.Option(False, "--json", help="JSON 输出(快捷方式,等同于 --format json)"),
|
|
774
|
+
):
|
|
775
|
+
"""查看笔记引用关系(反向链接)"""
|
|
776
|
+
try:
|
|
777
|
+
# 处理 --json 快捷方式
|
|
778
|
+
if json_output:
|
|
779
|
+
output_format = "json"
|
|
780
|
+
|
|
781
|
+
# 如果指定了知识库,临时切换
|
|
782
|
+
if kb:
|
|
783
|
+
from .config import use_kb
|
|
784
|
+
with use_kb(kb):
|
|
785
|
+
_refs_impl(note_id, search, output_format, json_output)
|
|
786
|
+
else:
|
|
787
|
+
_refs_impl(note_id, search, output_format, json_output)
|
|
788
|
+
|
|
789
|
+
except Exception as e:
|
|
790
|
+
result = {"success": False, "error": str(e)}
|
|
791
|
+
if output_format == "json":
|
|
792
|
+
print(output_json(result))
|
|
793
|
+
else:
|
|
794
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
795
|
+
raise typer.Exit(1)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _delete_impl(
|
|
799
|
+
note_id: str,
|
|
800
|
+
force: bool,
|
|
801
|
+
json_output: bool,
|
|
802
|
+
):
|
|
803
|
+
"""删除笔记的内部实现"""
|
|
804
|
+
# 先查找笔记
|
|
805
|
+
n = note.load_note_by_id(note_id)
|
|
806
|
+
if not n:
|
|
807
|
+
console.print(f"[red]Note not found: {note_id}[/red]")
|
|
808
|
+
raise typer.Exit(1)
|
|
809
|
+
|
|
810
|
+
# 确认删除
|
|
811
|
+
if not force:
|
|
812
|
+
if json_output:
|
|
813
|
+
console.print(f"Use --force to delete: {n.title}")
|
|
814
|
+
raise typer.Exit(1)
|
|
815
|
+
else:
|
|
816
|
+
console.print(f"Note: {n.title}")
|
|
817
|
+
confirm = input("Delete? (y/N): ")
|
|
818
|
+
if confirm.lower() != "y":
|
|
819
|
+
console.print("Cancelled")
|
|
820
|
+
raise typer.Exit(0)
|
|
821
|
+
|
|
822
|
+
# 执行删除
|
|
823
|
+
if note.delete_note(note_id):
|
|
824
|
+
result = {
|
|
825
|
+
"success": True,
|
|
826
|
+
"deleted": note_id,
|
|
827
|
+
"title": n.title,
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if json_output:
|
|
831
|
+
print(output_json(result))
|
|
832
|
+
else:
|
|
833
|
+
console.print(f"[green]✓[/green] Deleted: {n.title}")
|
|
834
|
+
else:
|
|
835
|
+
raise Exception("Failed to delete note")
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
@app.command()
|
|
839
|
+
def delete(
|
|
840
|
+
note_id: str = typer.Argument(..., help="笔记 ID"),
|
|
841
|
+
force: bool = typer.Option(False, "--force", "-f", help="强制删除不确认"),
|
|
842
|
+
kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
|
|
843
|
+
json_output: bool = typer.Option(True, "--json/--no-json", help="JSON 输出"),
|
|
844
|
+
):
|
|
845
|
+
"""删除笔记"""
|
|
846
|
+
try:
|
|
847
|
+
# 如果指定了知识库,临时切换
|
|
848
|
+
if kb:
|
|
849
|
+
from .config import use_kb
|
|
850
|
+
with use_kb(kb):
|
|
851
|
+
_delete_impl(note_id, force, json_output)
|
|
852
|
+
else:
|
|
853
|
+
_delete_impl(note_id, force, json_output)
|
|
854
|
+
|
|
855
|
+
except Exception as e:
|
|
856
|
+
result = {
|
|
857
|
+
"success": False,
|
|
858
|
+
"error": str(e),
|
|
859
|
+
}
|
|
860
|
+
if json_output:
|
|
861
|
+
print(output_json(result))
|
|
862
|
+
else:
|
|
863
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
864
|
+
raise typer.Exit(1)
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _query_impl(
|
|
868
|
+
query_str: str,
|
|
869
|
+
top: int,
|
|
870
|
+
graph_depth: int,
|
|
871
|
+
json_output: bool,
|
|
872
|
+
):
|
|
873
|
+
"""语义搜索 + 知识图谱联合查询的内部实现"""
|
|
874
|
+
# 1. 语义搜索
|
|
875
|
+
vector_results = note.search_notes(query_str, top_k=top)
|
|
876
|
+
|
|
877
|
+
# 2. 构建知识图谱并查找相关笔记
|
|
878
|
+
graph = KnowledgeGraph(config).build()
|
|
879
|
+
|
|
880
|
+
# 3. 为每个搜索结果查找图谱关联
|
|
881
|
+
enriched_results = []
|
|
882
|
+
for r in vector_results:
|
|
883
|
+
note_id = r["id"]
|
|
884
|
+
related = graph.get_related(note_id, depth=graph_depth)
|
|
885
|
+
|
|
886
|
+
# 获取相关笔记详情
|
|
887
|
+
related_notes = []
|
|
888
|
+
for depth_key, note_ids in related.items():
|
|
889
|
+
depth = int(depth_key.split("_")[1])
|
|
890
|
+
for nid in note_ids[:3]: # 限制每层的数量
|
|
891
|
+
n = note.load_note_by_id(nid)
|
|
892
|
+
if n:
|
|
893
|
+
related_notes.append({
|
|
894
|
+
"id": nid,
|
|
895
|
+
"title": n.title,
|
|
896
|
+
"type": n.type.value,
|
|
897
|
+
"depth": depth,
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
enriched_results.append({
|
|
901
|
+
**r,
|
|
902
|
+
"related_notes": related_notes,
|
|
903
|
+
"graph_stats": {
|
|
904
|
+
"neighbors": len(graph.get_neighbors(note_id)),
|
|
905
|
+
}
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
result = {
|
|
909
|
+
"query": query_str,
|
|
910
|
+
"semantic_results": len(vector_results),
|
|
911
|
+
"results": enriched_results,
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if json_output:
|
|
915
|
+
print(output_json(result))
|
|
916
|
+
else:
|
|
917
|
+
console.print(f"[bold]Query:[/bold] {query_str}")
|
|
918
|
+
console.print(f"[bold]Results:[/bold] {len(enriched_results)}\n")
|
|
919
|
+
|
|
920
|
+
for i, r in enumerate(enriched_results, 1):
|
|
921
|
+
score = r.get("score", 0)
|
|
922
|
+
panel_content = f"[cyan]{r['document'][:150]}...[/cyan]"
|
|
923
|
+
|
|
924
|
+
if r["related_notes"]:
|
|
925
|
+
panel_content += "\n\n[dim]Related:[/dim]"
|
|
926
|
+
for rel in r["related_notes"][:3]:
|
|
927
|
+
panel_content += f"\n - [{rel['type']}] {rel['title']}"
|
|
928
|
+
|
|
929
|
+
console.print(Panel(
|
|
930
|
+
panel_content,
|
|
931
|
+
title=f"{i}. [{score:.2f}] {r['metadata'].get('title', 'Untitled')}",
|
|
932
|
+
border_style="blue"
|
|
933
|
+
))
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
@app.command()
|
|
937
|
+
def query(
|
|
938
|
+
query_str: str = typer.Argument(..., help="搜索查询"),
|
|
939
|
+
top: int = typer.Option(5, "--top", "-n", help="返回结果数量"),
|
|
940
|
+
graph_depth: int = typer.Option(2, "--depth", "-d", help="图谱遍历深度"),
|
|
941
|
+
kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
|
|
942
|
+
json_output: bool = typer.Option(True, "--json/--no-json", help="JSON 输出"),
|
|
943
|
+
):
|
|
944
|
+
"""语义搜索 + 知识图谱联合查询"""
|
|
945
|
+
try:
|
|
946
|
+
# 如果指定了知识库,临时切换
|
|
947
|
+
if kb:
|
|
948
|
+
from .config import use_kb
|
|
949
|
+
with use_kb(kb):
|
|
950
|
+
_query_impl(query_str, top, graph_depth, json_output)
|
|
951
|
+
else:
|
|
952
|
+
_query_impl(query_str, top, graph_depth, json_output)
|
|
953
|
+
|
|
954
|
+
except Exception as e:
|
|
955
|
+
result = {"success": False, "error": str(e)}
|
|
956
|
+
if json_output:
|
|
957
|
+
print(output_json(result))
|
|
958
|
+
else:
|
|
959
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
960
|
+
raise typer.Exit(1)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _graph_impl(
|
|
964
|
+
note_id: Optional[str],
|
|
965
|
+
depth: int,
|
|
966
|
+
stats: bool,
|
|
967
|
+
orphans: bool,
|
|
968
|
+
output_format: str,
|
|
969
|
+
json_output: bool,
|
|
970
|
+
):
|
|
971
|
+
"""知识图谱可视化和分析的内部实现"""
|
|
972
|
+
kg = KnowledgeGraph(config).build()
|
|
973
|
+
|
|
974
|
+
if stats:
|
|
975
|
+
# 显示统计信息
|
|
976
|
+
graph_stats = kg.get_stats()
|
|
977
|
+
result = {
|
|
978
|
+
"total_nodes": graph_stats.total_nodes,
|
|
979
|
+
"total_edges": graph_stats.total_edges,
|
|
980
|
+
"avg_degree": round(graph_stats.avg_degree, 2),
|
|
981
|
+
"isolated_nodes": graph_stats.isolated_nodes,
|
|
982
|
+
"clusters": graph_stats.clusters,
|
|
983
|
+
"top_hubs": [
|
|
984
|
+
{"id": nid, "title": kg.graph.nodes[nid].get("title", ""), "degree": deg}
|
|
985
|
+
for nid, deg in graph_stats.hubs[:10]
|
|
986
|
+
],
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if output_format == "json":
|
|
990
|
+
print(output_json(result))
|
|
991
|
+
else:
|
|
992
|
+
table = Table(title="Knowledge Graph Statistics")
|
|
993
|
+
table.add_column("Metric", style="cyan")
|
|
994
|
+
table.add_column("Value", style="green")
|
|
995
|
+
table.add_row("Total Notes", str(graph_stats.total_nodes))
|
|
996
|
+
table.add_row("Total Links", str(graph_stats.total_edges))
|
|
997
|
+
table.add_row("Average Degree", f"{graph_stats.avg_degree:.2f}")
|
|
998
|
+
table.add_row("Isolated Notes", str(graph_stats.isolated_nodes))
|
|
999
|
+
table.add_row("Clusters", str(graph_stats.clusters))
|
|
1000
|
+
console.print(table)
|
|
1001
|
+
|
|
1002
|
+
console.print("\n[bold]Top Connected Notes:[/bold]")
|
|
1003
|
+
for nid, deg in graph_stats.hubs[:10]:
|
|
1004
|
+
title = kg.graph.nodes[nid].get("title", "Untitled")
|
|
1005
|
+
console.print(f" - {nid}: {title} ({deg} connections)")
|
|
1006
|
+
|
|
1007
|
+
elif orphans:
|
|
1008
|
+
# 显示孤立笔记
|
|
1009
|
+
orphan_ids = kg.get_orphan_notes()
|
|
1010
|
+
orphans_list = []
|
|
1011
|
+
for oid in orphan_ids:
|
|
1012
|
+
n = note.load_note_by_id(oid)
|
|
1013
|
+
if n:
|
|
1014
|
+
orphans_list.append({"id": oid, "title": n.title, "type": n.type.value})
|
|
1015
|
+
|
|
1016
|
+
result = {"orphans": orphans_list}
|
|
1017
|
+
|
|
1018
|
+
if output_format == "json":
|
|
1019
|
+
print(output_json(result))
|
|
1020
|
+
else:
|
|
1021
|
+
console.print(f"[bold]Orphan Notes ({len(orphans_list)}):[/bold]\n")
|
|
1022
|
+
for o in orphans_list:
|
|
1023
|
+
console.print(f" - [{o['type']}] {o['title']} ({o['id']})")
|
|
1024
|
+
|
|
1025
|
+
elif note_id:
|
|
1026
|
+
# 显示特定笔记的图谱
|
|
1027
|
+
if note_id not in kg.graph:
|
|
1028
|
+
console.print(f"[red]Note not found: {note_id}[/red]")
|
|
1029
|
+
raise typer.Exit(1)
|
|
1030
|
+
|
|
1031
|
+
related = kg.get_related(note_id, depth=depth)
|
|
1032
|
+
n = note.load_note_by_id(note_id)
|
|
1033
|
+
|
|
1034
|
+
result = {
|
|
1035
|
+
"note_id": note_id,
|
|
1036
|
+
"title": n.title if n else "",
|
|
1037
|
+
"related": related,
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if output_format == "json":
|
|
1041
|
+
print(output_json(result))
|
|
1042
|
+
else:
|
|
1043
|
+
tree = Tree(f"[bold]{n.title}[/bold] ({note_id})")
|
|
1044
|
+
|
|
1045
|
+
for depth_key, note_ids in related.items():
|
|
1046
|
+
depth_num = depth_key.split("_")[1]
|
|
1047
|
+
level = tree.add(f"[dim]Depth {depth_num}[/dim]")
|
|
1048
|
+
for nid in note_ids[:10]: # 限制显示数量
|
|
1049
|
+
rel_note = note.load_note_by_id(nid)
|
|
1050
|
+
if rel_note:
|
|
1051
|
+
level.add(f"[{rel_note.type.value}] {rel_note.title}")
|
|
1052
|
+
|
|
1053
|
+
console.print(tree)
|
|
1054
|
+
|
|
1055
|
+
else:
|
|
1056
|
+
# 显示整体图谱文本可视化
|
|
1057
|
+
viz = kg.visualize_text()
|
|
1058
|
+
console.print(viz)
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
@app.command()
|
|
1062
|
+
def graph(
|
|
1063
|
+
note_id: Optional[str] = typer.Option(None, "--note", "-n", help="查看特定笔记的图谱"),
|
|
1064
|
+
depth: int = typer.Option(2, "--depth", "-d", help="遍历深度"),
|
|
1065
|
+
stats: bool = typer.Option(False, "--stats", "-s", help="显示统计信息"),
|
|
1066
|
+
orphans: bool = typer.Option(False, "--orphans", "-o", help="显示孤立笔记"),
|
|
1067
|
+
kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
|
|
1068
|
+
output_format: str = typer.Option("table", "--format", "-f", help="输出格式: json, table"),
|
|
1069
|
+
json_output: bool = typer.Option(False, "--json", help="JSON 输出(快捷方式,等同于 --format json)"),
|
|
1070
|
+
):
|
|
1071
|
+
"""知识图谱可视化和分析"""
|
|
1072
|
+
try:
|
|
1073
|
+
# 处理 --json 快捷方式
|
|
1074
|
+
if json_output:
|
|
1075
|
+
output_format = "json"
|
|
1076
|
+
|
|
1077
|
+
# 如果指定了知识库,临时切换
|
|
1078
|
+
if kb:
|
|
1079
|
+
from .config import use_kb
|
|
1080
|
+
with use_kb(kb):
|
|
1081
|
+
_graph_impl(note_id, depth, stats, orphans, output_format, json_output)
|
|
1082
|
+
else:
|
|
1083
|
+
_graph_impl(note_id, depth, stats, orphans, output_format, json_output)
|
|
1084
|
+
|
|
1085
|
+
except Exception as e:
|
|
1086
|
+
result = {"success": False, "error": str(e)}
|
|
1087
|
+
if output_format == "json":
|
|
1088
|
+
print(output_json(result))
|
|
1089
|
+
else:
|
|
1090
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
1091
|
+
raise typer.Exit(1)
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _daily_impl(
|
|
1095
|
+
date: Optional[str],
|
|
1096
|
+
output_format: str,
|
|
1097
|
+
json_output: bool,
|
|
1098
|
+
):
|
|
1099
|
+
"""查看某天笔记的内部实现"""
|
|
1100
|
+
if date:
|
|
1101
|
+
target_date = datetime.strptime(date, "%Y-%m-%d")
|
|
1102
|
+
else:
|
|
1103
|
+
target_date = datetime.now()
|
|
1104
|
+
|
|
1105
|
+
date_str = target_date.strftime("%Y%m%d")
|
|
1106
|
+
|
|
1107
|
+
# 查找当天的笔记
|
|
1108
|
+
all_notes = note.list_notes()
|
|
1109
|
+
daily_notes = [n for n in all_notes if n.id.startswith(date_str)]
|
|
1110
|
+
|
|
1111
|
+
result = {
|
|
1112
|
+
"date": target_date.strftime("%Y-%m-%d"),
|
|
1113
|
+
"total": len(daily_notes),
|
|
1114
|
+
"notes": [
|
|
1115
|
+
{
|
|
1116
|
+
"id": n.id,
|
|
1117
|
+
"title": n.title,
|
|
1118
|
+
"type": n.type.value,
|
|
1119
|
+
"created": n.created.isoformat() if n.created else None,
|
|
1120
|
+
}
|
|
1121
|
+
for n in daily_notes
|
|
1122
|
+
],
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if output_format == "json":
|
|
1126
|
+
print(output_json(result))
|
|
1127
|
+
else:
|
|
1128
|
+
console.print(f"[bold]Notes for {target_date.strftime('%Y-%m-%d')}:[/bold]\n")
|
|
1129
|
+
if daily_notes:
|
|
1130
|
+
for n in daily_notes:
|
|
1131
|
+
console.print(f"- [{n.type.value}] {n.title}")
|
|
1132
|
+
else:
|
|
1133
|
+
console.print("[dim]No notes found for this date.[/dim]")
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
@app.command()
|
|
1137
|
+
def daily(
|
|
1138
|
+
date: Optional[str] = typer.Option(None, "--date", "-d", help="日期 (YYYY-MM-DD, 默认今天)"),
|
|
1139
|
+
kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
|
|
1140
|
+
output_format: str = typer.Option("table", "--format", "-f", help="输出格式: json, table"),
|
|
1141
|
+
json_output: bool = typer.Option(False, "--json", help="JSON 输出(快捷方式,等同于 --format json)"),
|
|
1142
|
+
):
|
|
1143
|
+
"""查看某天的笔记(默认今天)"""
|
|
1144
|
+
try:
|
|
1145
|
+
# 处理 --json 快捷方式
|
|
1146
|
+
if json_output:
|
|
1147
|
+
output_format = "json"
|
|
1148
|
+
|
|
1149
|
+
# 如果指定了知识库,临时切换
|
|
1150
|
+
if kb:
|
|
1151
|
+
from .config import use_kb
|
|
1152
|
+
with use_kb(kb):
|
|
1153
|
+
_daily_impl(date, output_format, json_output)
|
|
1154
|
+
else:
|
|
1155
|
+
_daily_impl(date, output_format, json_output)
|
|
1156
|
+
|
|
1157
|
+
except Exception as e:
|
|
1158
|
+
result = {"success": False, "error": str(e)}
|
|
1159
|
+
if output_format == "json":
|
|
1160
|
+
print(output_json(result))
|
|
1161
|
+
else:
|
|
1162
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
1163
|
+
raise typer.Exit(1)
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def _inbox_impl(
|
|
1167
|
+
limit: int,
|
|
1168
|
+
output_format: str,
|
|
1169
|
+
json_output: bool,
|
|
1170
|
+
):
|
|
1171
|
+
"""查看临时笔记的内部实现"""
|
|
1172
|
+
fleeting_notes = note.list_notes(note_type=NoteType.FLEETING, limit=limit)
|
|
1173
|
+
|
|
1174
|
+
result = {
|
|
1175
|
+
"total": len(fleeting_notes),
|
|
1176
|
+
"notes": [
|
|
1177
|
+
{
|
|
1178
|
+
"id": n.id,
|
|
1179
|
+
"title": n.title,
|
|
1180
|
+
"created": n.created.isoformat() if n.created else None,
|
|
1181
|
+
"filepath": str(n.filepath) if n.filepath else None,
|
|
1182
|
+
}
|
|
1183
|
+
for n in fleeting_notes
|
|
1184
|
+
],
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
if output_format == "json":
|
|
1188
|
+
print(output_json(result))
|
|
1189
|
+
else:
|
|
1190
|
+
console.print(f"[bold]Fleeting Notes ({len(fleeting_notes)}):[/bold]\n")
|
|
1191
|
+
for n in fleeting_notes:
|
|
1192
|
+
time_str = n.created.strftime("%H:%M") if n.created else ""
|
|
1193
|
+
console.print(f"- [{time_str}] {n.title}")
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def _suggest_links_impl(
|
|
1197
|
+
content: str,
|
|
1198
|
+
top_k: int,
|
|
1199
|
+
threshold: float,
|
|
1200
|
+
output_format: str,
|
|
1201
|
+
json_output: bool,
|
|
1202
|
+
):
|
|
1203
|
+
"""推荐链接笔记的内部实现"""
|
|
1204
|
+
suggestions = note.suggest_links(content, top_k=top_k, threshold=threshold)
|
|
1205
|
+
|
|
1206
|
+
result = {
|
|
1207
|
+
"content": content[:200] + "..." if len(content) > 200 else content,
|
|
1208
|
+
"total_suggestions": len(suggestions),
|
|
1209
|
+
"threshold": threshold,
|
|
1210
|
+
"suggestions": suggestions,
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if output_format == "json":
|
|
1214
|
+
print(output_json(result))
|
|
1215
|
+
else:
|
|
1216
|
+
if suggestions:
|
|
1217
|
+
console.print(f"[bold]Suggested links (confidence > {threshold}):[/bold]\n")
|
|
1218
|
+
for i, s in enumerate(suggestions, 1):
|
|
1219
|
+
match_badge = "[cyan]semantic[/cyan]" if s["match_type"] == "semantic" else "[yellow]keyword[/yellow]"
|
|
1220
|
+
console.print(f"{i}. [{s['score']:.2f}] {s['title']} ({s['id']})")
|
|
1221
|
+
console.print(f" Match type: {match_badge}")
|
|
1222
|
+
if s.get("matched_keywords"):
|
|
1223
|
+
console.print(f" Keywords: {', '.join(s['matched_keywords'])}")
|
|
1224
|
+
console.print()
|
|
1225
|
+
|
|
1226
|
+
console.print("[dim]Use with: jfox add \"... [[note title]] ...\"[/dim]")
|
|
1227
|
+
else:
|
|
1228
|
+
console.print(f"[dim]No suggestions found (threshold: {threshold})[/dim]")
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
@app.command()
|
|
1232
|
+
def suggest_links(
|
|
1233
|
+
content: str = typer.Argument(..., help="内容文本(用于推荐相关笔记)"),
|
|
1234
|
+
top_k: int = typer.Option(5, "--top", "-n", help="返回建议数量"),
|
|
1235
|
+
threshold: float = typer.Option(0.6, "--threshold", "-t", help="相似度阈值 (0-1)"),
|
|
1236
|
+
kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
|
|
1237
|
+
output_format: str = typer.Option("table", "--format", "-f", help="输出格式: json, table"),
|
|
1238
|
+
json_output: bool = typer.Option(False, "--json", help="JSON 输出(快捷方式,等同于 --format json)"),
|
|
1239
|
+
):
|
|
1240
|
+
"""
|
|
1241
|
+
根据内容推荐可以链接的已有笔记
|
|
1242
|
+
|
|
1243
|
+
使用语义相似度和关键词匹配混合策略,帮助发现知识间的联系。
|
|
1244
|
+
|
|
1245
|
+
示例:
|
|
1246
|
+
jfox suggest-links "今天学习了 Python 的 async/await 机制"
|
|
1247
|
+
jfox suggest-links "笔记内容" --top 10 --threshold 0.5
|
|
1248
|
+
"""
|
|
1249
|
+
# 处理 --json 快捷方式
|
|
1250
|
+
if json_output:
|
|
1251
|
+
output_format = "json"
|
|
1252
|
+
|
|
1253
|
+
try:
|
|
1254
|
+
# 如果指定了知识库,临时切换
|
|
1255
|
+
if kb:
|
|
1256
|
+
from .config import use_kb
|
|
1257
|
+
with use_kb(kb):
|
|
1258
|
+
_suggest_links_impl(content, top_k, threshold, output_format, json_output)
|
|
1259
|
+
else:
|
|
1260
|
+
_suggest_links_impl(content, top_k, threshold, output_format, json_output)
|
|
1261
|
+
|
|
1262
|
+
except Exception as e:
|
|
1263
|
+
result = {"success": False, "error": str(e)}
|
|
1264
|
+
if output_format == "json":
|
|
1265
|
+
print(output_json(result))
|
|
1266
|
+
else:
|
|
1267
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
1268
|
+
raise typer.Exit(1)
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
@app.command()
|
|
1272
|
+
def inbox(
|
|
1273
|
+
limit: int = typer.Option(20, "--limit", "-n", help="显示数量"),
|
|
1274
|
+
kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
|
|
1275
|
+
output_format: str = typer.Option("table", "--format", "-f", help="输出格式: json, table"),
|
|
1276
|
+
json_output: bool = typer.Option(False, "--json", help="JSON 输出(快捷方式,等同于 --format json)"),
|
|
1277
|
+
):
|
|
1278
|
+
"""查看临时笔记 (Fleeting Notes)"""
|
|
1279
|
+
try:
|
|
1280
|
+
# 处理 --json 快捷方式
|
|
1281
|
+
if json_output:
|
|
1282
|
+
output_format = "json"
|
|
1283
|
+
|
|
1284
|
+
# 如果指定了知识库,临时切换
|
|
1285
|
+
if kb:
|
|
1286
|
+
from .config import use_kb
|
|
1287
|
+
with use_kb(kb):
|
|
1288
|
+
_inbox_impl(limit, output_format, json_output)
|
|
1289
|
+
else:
|
|
1290
|
+
_inbox_impl(limit, output_format, json_output)
|
|
1291
|
+
|
|
1292
|
+
except Exception as e:
|
|
1293
|
+
result = {"success": False, "error": str(e)}
|
|
1294
|
+
if output_format == "json":
|
|
1295
|
+
print(output_json(result))
|
|
1296
|
+
else:
|
|
1297
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
1298
|
+
raise typer.Exit(1)
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
@app.command()
|
|
1302
|
+
def index(
|
|
1303
|
+
action: str = typer.Argument("status", help="操作: status, rebuild, verify, rebuild-bm25, bm25-status"),
|
|
1304
|
+
output_format: str = typer.Option("table", "--format", "-f", help="输出格式: json, table"),
|
|
1305
|
+
json_output: bool = typer.Option(False, "--json", help="JSON 输出(快捷方式,等同于 --format json)"),
|
|
1306
|
+
):
|
|
1307
|
+
"""索引管理:查看状态、重建索引、验证完整性"""
|
|
1308
|
+
try:
|
|
1309
|
+
# 处理 --json 快捷方式
|
|
1310
|
+
if json_output:
|
|
1311
|
+
output_format = "json"
|
|
1312
|
+
|
|
1313
|
+
if action == "rebuild-bm25":
|
|
1314
|
+
# 重建 BM25 索引
|
|
1315
|
+
from .bm25_index import get_bm25_index
|
|
1316
|
+
from . import note as note_module
|
|
1317
|
+
|
|
1318
|
+
console.print("[yellow]Rebuilding BM25 index...[/yellow]")
|
|
1319
|
+
bm25_index = get_bm25_index()
|
|
1320
|
+
notes = note_module.list_notes(limit=10000)
|
|
1321
|
+
success = bm25_index.rebuild_from_notes(notes)
|
|
1322
|
+
|
|
1323
|
+
result = {
|
|
1324
|
+
"success": success,
|
|
1325
|
+
"indexed": len(notes),
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
if output_format == "json":
|
|
1329
|
+
print(output_json(result))
|
|
1330
|
+
else:
|
|
1331
|
+
if success:
|
|
1332
|
+
console.print(f"[green]✓[/green] BM25 index rebuilt: {len(notes)} notes")
|
|
1333
|
+
else:
|
|
1334
|
+
console.print("[red]✗[/red] Failed to rebuild BM25 index")
|
|
1335
|
+
|
|
1336
|
+
elif action == "bm25-status":
|
|
1337
|
+
# 查看 BM25 索引状态
|
|
1338
|
+
from .bm25_index import get_bm25_index
|
|
1339
|
+
|
|
1340
|
+
bm25_index = get_bm25_index()
|
|
1341
|
+
stats = bm25_index.get_stats()
|
|
1342
|
+
|
|
1343
|
+
result = {
|
|
1344
|
+
"bm25_index": stats,
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if output_format == "json":
|
|
1348
|
+
print(output_json(result))
|
|
1349
|
+
else:
|
|
1350
|
+
table = Table(title="BM25 Index Status")
|
|
1351
|
+
table.add_column("Property", style="cyan")
|
|
1352
|
+
table.add_column("Value", style="green")
|
|
1353
|
+
table.add_row("Indexed Documents", str(stats["indexed"]))
|
|
1354
|
+
table.add_row("Index Version", str(stats["version"]))
|
|
1355
|
+
table.add_row("Index File", str(stats["index_path"]))
|
|
1356
|
+
table.add_row("Index Exists", "Yes" if stats["index_exists"] else "No")
|
|
1357
|
+
console.print(table)
|
|
1358
|
+
|
|
1359
|
+
else:
|
|
1360
|
+
vector_store = get_vector_store()
|
|
1361
|
+
indexer = Indexer(config, vector_store)
|
|
1362
|
+
|
|
1363
|
+
if action == "status":
|
|
1364
|
+
stats = indexer.get_stats()
|
|
1365
|
+
vs_stats = vector_store.get_stats()
|
|
1366
|
+
|
|
1367
|
+
result = {
|
|
1368
|
+
"total_indexed": stats.total_indexed,
|
|
1369
|
+
"last_indexed": stats.last_indexed.isoformat() if stats.last_indexed else None,
|
|
1370
|
+
"pending_changes": stats.pending_changes,
|
|
1371
|
+
"vector_store": vs_stats,
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
if output_format == "json":
|
|
1375
|
+
print(output_json(result))
|
|
1376
|
+
else:
|
|
1377
|
+
table = Table(title="Index Status")
|
|
1378
|
+
table.add_column("Property", style="cyan")
|
|
1379
|
+
table.add_column("Value", style="green")
|
|
1380
|
+
table.add_row("Total Indexed", str(stats.total_indexed))
|
|
1381
|
+
table.add_row("Last Indexed", str(stats.last_indexed or "Never"))
|
|
1382
|
+
table.add_row("Pending Changes", str(stats.pending_changes))
|
|
1383
|
+
table.add_row("Vector Store Notes", str(vs_stats.get("total_notes", 0)))
|
|
1384
|
+
console.print(table)
|
|
1385
|
+
|
|
1386
|
+
if stats.errors:
|
|
1387
|
+
console.print("\n[yellow]Recent Errors:[/yellow]")
|
|
1388
|
+
for err in stats.errors[-5:]:
|
|
1389
|
+
console.print(f" - {err}")
|
|
1390
|
+
|
|
1391
|
+
elif action == "rebuild":
|
|
1392
|
+
console.print("[yellow]Rebuilding index...[/yellow]")
|
|
1393
|
+
count = indexer.index_all()
|
|
1394
|
+
|
|
1395
|
+
result = {
|
|
1396
|
+
"success": True,
|
|
1397
|
+
"indexed": count,
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
if output_format == "json":
|
|
1401
|
+
print(output_json(result))
|
|
1402
|
+
else:
|
|
1403
|
+
console.print(f"[green]✓[/green] Indexed {count} notes")
|
|
1404
|
+
|
|
1405
|
+
elif action == "verify":
|
|
1406
|
+
verification = indexer.verify_index()
|
|
1407
|
+
|
|
1408
|
+
result = verification
|
|
1409
|
+
|
|
1410
|
+
if output_format == "json":
|
|
1411
|
+
print(output_json(result))
|
|
1412
|
+
else:
|
|
1413
|
+
if verification["healthy"]:
|
|
1414
|
+
console.print("[green]✓[/green] Index is healthy")
|
|
1415
|
+
else:
|
|
1416
|
+
console.print("[yellow]⚠[/yellow] Index has issues")
|
|
1417
|
+
|
|
1418
|
+
console.print(f" Files: {verification['total_files']}")
|
|
1419
|
+
console.print(f" Indexed: {verification['total_indexed']}")
|
|
1420
|
+
|
|
1421
|
+
if verification["missing_from_index"]:
|
|
1422
|
+
console.print(f"\n[yellow]Missing from index ({len(verification['missing_from_index'])}):[/yellow]")
|
|
1423
|
+
for nid in verification["missing_from_index"][:5]:
|
|
1424
|
+
console.print(f" - {nid}")
|
|
1425
|
+
|
|
1426
|
+
if verification["orphaned_in_index"]:
|
|
1427
|
+
console.print(f"\n[yellow]Orphaned in index ({len(verification['orphaned_in_index'])}):[/yellow]")
|
|
1428
|
+
for nid in verification["orphaned_in_index"][:5]:
|
|
1429
|
+
console.print(f" - {nid}")
|
|
1430
|
+
|
|
1431
|
+
else:
|
|
1432
|
+
console.print(f"[red]Unknown action: {action}. Use: status, rebuild, verify, rebuild-bm25, bm25-status[/red]")
|
|
1433
|
+
raise typer.Exit(1)
|
|
1434
|
+
|
|
1435
|
+
except Exception as e:
|
|
1436
|
+
result = {"success": False, "error": str(e)}
|
|
1437
|
+
if json_output:
|
|
1438
|
+
print(output_json(result))
|
|
1439
|
+
else:
|
|
1440
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
1441
|
+
raise typer.Exit(1)
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
# =============================================================================
|
|
1445
|
+
# 知识库管理命令
|
|
1446
|
+
# =============================================================================
|
|
1447
|
+
|
|
1448
|
+
@app.command()
|
|
1449
|
+
def kb(
|
|
1450
|
+
action: str = typer.Argument("list", help="操作: list, create, switch, remove, info, current, rename"),
|
|
1451
|
+
name: Optional[str] = typer.Argument(None, help="知识库名称"),
|
|
1452
|
+
new_name: Optional[str] = typer.Argument(None, help="新名称(仅 rename 使用)"),
|
|
1453
|
+
path: Optional[str] = typer.Option(None, "--path", "-p", help="知识库路径(仅 create 使用)"),
|
|
1454
|
+
description: Optional[str] = typer.Option(None, "--desc", "-d", help="知识库描述"),
|
|
1455
|
+
force: bool = typer.Option(False, "--force", "-f", help="强制操作(删除时跳过确认)"),
|
|
1456
|
+
set_default: bool = typer.Option(False, "--default", help="创建后设为默认"),
|
|
1457
|
+
output_format: str = typer.Option("table", "--format", help="输出格式: json, table, csv, yaml(仅 list/info/current 有效)"),
|
|
1458
|
+
json_output: bool = typer.Option(False, "--json", help="JSON 输出(快捷方式,等同于 --format json)"),
|
|
1459
|
+
):
|
|
1460
|
+
"""
|
|
1461
|
+
知识库管理:列出、创建、切换、删除知识库
|
|
1462
|
+
|
|
1463
|
+
示例:
|
|
1464
|
+
jfox kb list # 列出所有知识库
|
|
1465
|
+
jfox kb create work # 创建名为 work 的知识库(~/.zettelkasten/work/)
|
|
1466
|
+
jfox kb create work --desc "工作笔记"
|
|
1467
|
+
jfox kb switch work # 切换到 work 知识库
|
|
1468
|
+
jfox kb current # 显示当前知识库
|
|
1469
|
+
jfox kb info work # 查看 work 知识库详情
|
|
1470
|
+
jfox kb remove temp --force # 强制删除 temp 知识库
|
|
1471
|
+
jfox kb rename old new # 重命名知识库
|
|
1472
|
+
"""
|
|
1473
|
+
try:
|
|
1474
|
+
manager = get_kb_manager()
|
|
1475
|
+
|
|
1476
|
+
if action == "list":
|
|
1477
|
+
# 列出所有知识库
|
|
1478
|
+
from .formatters import OutputFormatter
|
|
1479
|
+
|
|
1480
|
+
stats_list = manager.list_all()
|
|
1481
|
+
|
|
1482
|
+
result = {
|
|
1483
|
+
"current": manager.config_manager.get_default_kb_name(),
|
|
1484
|
+
"knowledge_bases": [
|
|
1485
|
+
{
|
|
1486
|
+
"name": s.name,
|
|
1487
|
+
"path": str(s.path),
|
|
1488
|
+
"total_notes": s.total_notes,
|
|
1489
|
+
"by_type": s.by_type,
|
|
1490
|
+
"created": s.created,
|
|
1491
|
+
"last_used": s.last_used,
|
|
1492
|
+
"description": s.description,
|
|
1493
|
+
"is_current": s.is_current,
|
|
1494
|
+
}
|
|
1495
|
+
for s in stats_list
|
|
1496
|
+
]
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
# 处理 --json 快捷方式
|
|
1500
|
+
if json_output:
|
|
1501
|
+
output_format = "json"
|
|
1502
|
+
|
|
1503
|
+
# 根据格式输出
|
|
1504
|
+
if output_format == "json":
|
|
1505
|
+
print(OutputFormatter.to_json(result))
|
|
1506
|
+
elif output_format == "yaml":
|
|
1507
|
+
print(OutputFormatter.to_yaml(result))
|
|
1508
|
+
elif output_format == "csv":
|
|
1509
|
+
# CSV 格式
|
|
1510
|
+
flat_data = []
|
|
1511
|
+
for s in stats_list:
|
|
1512
|
+
flat_data.append({
|
|
1513
|
+
"name": s.name,
|
|
1514
|
+
"path": str(s.path),
|
|
1515
|
+
"total_notes": s.total_notes,
|
|
1516
|
+
"fleeting": s.by_type.get('fleeting', 0),
|
|
1517
|
+
"literature": s.by_type.get('literature', 0),
|
|
1518
|
+
"permanent": s.by_type.get('permanent', 0),
|
|
1519
|
+
"created": s.created,
|
|
1520
|
+
"last_used": s.last_used or "",
|
|
1521
|
+
"description": s.description or "",
|
|
1522
|
+
"is_current": "*" if s.is_current else "",
|
|
1523
|
+
})
|
|
1524
|
+
console.print(OutputFormatter.to_csv(flat_data, headers=["name", "path", "total_notes", "fleeting", "literature", "permanent", "created", "last_used", "description", "is_current"]))
|
|
1525
|
+
elif output_format == "table":
|
|
1526
|
+
table = Table(title="Knowledge Bases")
|
|
1527
|
+
table.add_column("Status", style="dim", justify="center")
|
|
1528
|
+
table.add_column("Name", style="cyan")
|
|
1529
|
+
table.add_column("Path", style="green")
|
|
1530
|
+
table.add_column("Notes", justify="right")
|
|
1531
|
+
table.add_column("F/L/P", justify="center")
|
|
1532
|
+
table.add_column("Last Used", style="dim")
|
|
1533
|
+
|
|
1534
|
+
for s in stats_list:
|
|
1535
|
+
status = "*" if s.is_current else ""
|
|
1536
|
+
types_str = f"{s.by_type.get('fleeting', 0)}/{s.by_type.get('literature', 0)}/{s.by_type.get('permanent', 0)}"
|
|
1537
|
+
last_used = s.last_used[:10] if s.last_used else "Never"
|
|
1538
|
+
table.add_row(
|
|
1539
|
+
status,
|
|
1540
|
+
s.name,
|
|
1541
|
+
str(s.path)[:40],
|
|
1542
|
+
str(s.total_notes),
|
|
1543
|
+
types_str,
|
|
1544
|
+
last_used,
|
|
1545
|
+
)
|
|
1546
|
+
|
|
1547
|
+
console.print(table)
|
|
1548
|
+
console.print("\n[dim]* = current default[/dim]")
|
|
1549
|
+
else:
|
|
1550
|
+
console.print(f"[red]Error:[/red] Unsupported format: {output_format}")
|
|
1551
|
+
raise typer.Exit(1)
|
|
1552
|
+
|
|
1553
|
+
elif action == "create":
|
|
1554
|
+
if not name:
|
|
1555
|
+
console.print("[red]Error: name is required for create[/red]")
|
|
1556
|
+
raise typer.Exit(1)
|
|
1557
|
+
|
|
1558
|
+
path_obj = Path(path) if path else None
|
|
1559
|
+
|
|
1560
|
+
# 用户显式指定路径时,验证必须在管理目录下
|
|
1561
|
+
if path_obj is not None:
|
|
1562
|
+
from jfox.global_config import DEFAULT_KB_PATH
|
|
1563
|
+
resolved = path_obj.expanduser().resolve()
|
|
1564
|
+
kb_root = DEFAULT_KB_PATH.resolve()
|
|
1565
|
+
try:
|
|
1566
|
+
resolved.relative_to(kb_root)
|
|
1567
|
+
except ValueError:
|
|
1568
|
+
console.print(
|
|
1569
|
+
f"[red]✗[/red] Path '{resolved}' is outside managed directory "
|
|
1570
|
+
f"'{kb_root}'. All knowledge bases must be under {kb_root}/"
|
|
1571
|
+
)
|
|
1572
|
+
raise typer.Exit(1)
|
|
1573
|
+
|
|
1574
|
+
success, message = manager.create(
|
|
1575
|
+
name=name,
|
|
1576
|
+
path=path_obj,
|
|
1577
|
+
description=description,
|
|
1578
|
+
set_as_default=set_default
|
|
1579
|
+
)
|
|
1580
|
+
|
|
1581
|
+
result = {"success": success, "message": message}
|
|
1582
|
+
|
|
1583
|
+
if json_output:
|
|
1584
|
+
print(output_json(result))
|
|
1585
|
+
else:
|
|
1586
|
+
if success:
|
|
1587
|
+
console.print(f"[green]✓[/green] {message}")
|
|
1588
|
+
else:
|
|
1589
|
+
console.print(f"[red]✗[/red] {message}")
|
|
1590
|
+
raise typer.Exit(1)
|
|
1591
|
+
|
|
1592
|
+
elif action == "switch":
|
|
1593
|
+
if not name:
|
|
1594
|
+
console.print("[red]Error: name is required for switch[/red]")
|
|
1595
|
+
raise typer.Exit(1)
|
|
1596
|
+
|
|
1597
|
+
success, message = manager.switch(name)
|
|
1598
|
+
result = {"success": success, "message": message}
|
|
1599
|
+
|
|
1600
|
+
if json_output:
|
|
1601
|
+
print(output_json(result))
|
|
1602
|
+
else:
|
|
1603
|
+
if success:
|
|
1604
|
+
console.print(f"[green]✓[/green] {message}")
|
|
1605
|
+
else:
|
|
1606
|
+
console.print(f"[red]✗[/red] {message}")
|
|
1607
|
+
raise typer.Exit(1)
|
|
1608
|
+
|
|
1609
|
+
elif action == "remove" or action == "delete":
|
|
1610
|
+
if not name:
|
|
1611
|
+
console.print("[red]Error: name is required for remove[/red]")
|
|
1612
|
+
raise typer.Exit(1)
|
|
1613
|
+
|
|
1614
|
+
# 确认删除
|
|
1615
|
+
if not force and not json_output:
|
|
1616
|
+
kb_info = manager.get_info(name)
|
|
1617
|
+
if kb_info:
|
|
1618
|
+
console.print(f"Knowledge base: {kb_info.name}")
|
|
1619
|
+
console.print(f"Path: {kb_info.path}")
|
|
1620
|
+
console.print(f"Notes: {kb_info.total_notes}")
|
|
1621
|
+
confirm = input("\nDelete this knowledge base? [y/N]: ")
|
|
1622
|
+
if confirm.lower() != "y":
|
|
1623
|
+
console.print("Cancelled")
|
|
1624
|
+
raise typer.Exit(0)
|
|
1625
|
+
|
|
1626
|
+
success, message = manager.remove(name, delete_data=force)
|
|
1627
|
+
result = {"success": success, "message": message}
|
|
1628
|
+
|
|
1629
|
+
if json_output:
|
|
1630
|
+
print(output_json(result))
|
|
1631
|
+
else:
|
|
1632
|
+
if success:
|
|
1633
|
+
console.print(f"[green]✓[/green] {message}")
|
|
1634
|
+
else:
|
|
1635
|
+
console.print(f"[red]✗[/red] {message}")
|
|
1636
|
+
raise typer.Exit(1)
|
|
1637
|
+
|
|
1638
|
+
elif action == "current":
|
|
1639
|
+
# 显示当前知识库
|
|
1640
|
+
from .formatters import OutputFormatter
|
|
1641
|
+
|
|
1642
|
+
current_name = manager.config_manager.get_default_kb_name()
|
|
1643
|
+
|
|
1644
|
+
if not current_name:
|
|
1645
|
+
console.print("[red]No default knowledge base configured[/red]")
|
|
1646
|
+
raise typer.Exit(1)
|
|
1647
|
+
|
|
1648
|
+
stats = manager.get_info(current_name)
|
|
1649
|
+
if not stats:
|
|
1650
|
+
console.print(f"[red]Knowledge base '{current_name}' not found[/red]")
|
|
1651
|
+
raise typer.Exit(1)
|
|
1652
|
+
|
|
1653
|
+
result = {
|
|
1654
|
+
"name": stats.name,
|
|
1655
|
+
"path": str(stats.path),
|
|
1656
|
+
"total_notes": stats.total_notes,
|
|
1657
|
+
"by_type": stats.by_type,
|
|
1658
|
+
"created": stats.created,
|
|
1659
|
+
"last_used": stats.last_used,
|
|
1660
|
+
"description": stats.description,
|
|
1661
|
+
"is_current": True,
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
# 处理 --json 快捷方式
|
|
1665
|
+
if json_output:
|
|
1666
|
+
output_format = "json"
|
|
1667
|
+
|
|
1668
|
+
# 根据格式输出
|
|
1669
|
+
if output_format == "json":
|
|
1670
|
+
print(OutputFormatter.to_json(result))
|
|
1671
|
+
elif output_format == "yaml":
|
|
1672
|
+
print(OutputFormatter.to_yaml(result))
|
|
1673
|
+
elif output_format == "table":
|
|
1674
|
+
# 表格格式输出
|
|
1675
|
+
table = Table(title=f"Current Knowledge Base: {stats.name}")
|
|
1676
|
+
table.add_column("Property", style="cyan")
|
|
1677
|
+
table.add_column("Value", style="green")
|
|
1678
|
+
|
|
1679
|
+
table.add_row("Name", stats.name)
|
|
1680
|
+
table.add_row("Path", str(stats.path))
|
|
1681
|
+
table.add_row("Description", stats.description or "N/A")
|
|
1682
|
+
table.add_row("Created", stats.created or "Unknown")
|
|
1683
|
+
table.add_row("Last Used", stats.last_used or "Never")
|
|
1684
|
+
table.add_row("Total Notes", str(stats.total_notes))
|
|
1685
|
+
table.add_row("Fleeting", str(stats.by_type.get('fleeting', 0)))
|
|
1686
|
+
table.add_row("Literature", str(stats.by_type.get('literature', 0)))
|
|
1687
|
+
table.add_row("Permanent", str(stats.by_type.get('permanent', 0)))
|
|
1688
|
+
|
|
1689
|
+
console.print(table)
|
|
1690
|
+
else:
|
|
1691
|
+
console.print(f"[red]Error:[/red] Unsupported format: {output_format}")
|
|
1692
|
+
raise typer.Exit(1)
|
|
1693
|
+
|
|
1694
|
+
elif action == "info":
|
|
1695
|
+
# 如果没有指定名称,显示当前知识库
|
|
1696
|
+
from .formatters import OutputFormatter
|
|
1697
|
+
|
|
1698
|
+
target_name = name or manager.config_manager.get_default_kb_name()
|
|
1699
|
+
|
|
1700
|
+
stats = manager.get_info(target_name)
|
|
1701
|
+
if not stats:
|
|
1702
|
+
console.print(f"[red]Knowledge base '{target_name}' not found[/red]")
|
|
1703
|
+
raise typer.Exit(1)
|
|
1704
|
+
|
|
1705
|
+
result = {
|
|
1706
|
+
"name": stats.name,
|
|
1707
|
+
"path": str(stats.path),
|
|
1708
|
+
"total_notes": stats.total_notes,
|
|
1709
|
+
"by_type": stats.by_type,
|
|
1710
|
+
"created": stats.created,
|
|
1711
|
+
"last_used": stats.last_used,
|
|
1712
|
+
"description": stats.description,
|
|
1713
|
+
"is_current": stats.is_current,
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
# 处理 --json 快捷方式
|
|
1717
|
+
if json_output:
|
|
1718
|
+
output_format = "json"
|
|
1719
|
+
|
|
1720
|
+
# 根据格式输出
|
|
1721
|
+
if output_format == "json":
|
|
1722
|
+
print(OutputFormatter.to_json(result))
|
|
1723
|
+
elif output_format == "yaml":
|
|
1724
|
+
print(OutputFormatter.to_yaml(result))
|
|
1725
|
+
elif output_format == "table":
|
|
1726
|
+
# 表格格式输出
|
|
1727
|
+
title = f"Knowledge Base: {stats.name}"
|
|
1728
|
+
if stats.is_current:
|
|
1729
|
+
title += " [current]"
|
|
1730
|
+
table = Table(title=title)
|
|
1731
|
+
table.add_column("Property", style="cyan")
|
|
1732
|
+
table.add_column("Value", style="green")
|
|
1733
|
+
|
|
1734
|
+
table.add_row("Name", stats.name)
|
|
1735
|
+
table.add_row("Path", str(stats.path))
|
|
1736
|
+
table.add_row("Description", stats.description or "N/A")
|
|
1737
|
+
table.add_row("Created", stats.created or "Unknown")
|
|
1738
|
+
table.add_row("Last Used", stats.last_used or "Never")
|
|
1739
|
+
table.add_row("Total Notes", str(stats.total_notes))
|
|
1740
|
+
table.add_row("Fleeting", str(stats.by_type.get('fleeting', 0)))
|
|
1741
|
+
table.add_row("Literature", str(stats.by_type.get('literature', 0)))
|
|
1742
|
+
table.add_row("Permanent", str(stats.by_type.get('permanent', 0)))
|
|
1743
|
+
|
|
1744
|
+
console.print(table)
|
|
1745
|
+
else:
|
|
1746
|
+
console.print(f"[red]Error:[/red] Unsupported format: {output_format}")
|
|
1747
|
+
raise typer.Exit(1)
|
|
1748
|
+
|
|
1749
|
+
elif action == "rename":
|
|
1750
|
+
if not name or not new_name:
|
|
1751
|
+
console.print("[red]Error: both old and new name are required for rename[/red]")
|
|
1752
|
+
raise typer.Exit(1)
|
|
1753
|
+
|
|
1754
|
+
success, message = manager.rename(name, new_name)
|
|
1755
|
+
result = {"success": success, "message": message}
|
|
1756
|
+
|
|
1757
|
+
if json_output:
|
|
1758
|
+
print(output_json(result))
|
|
1759
|
+
else:
|
|
1760
|
+
if success:
|
|
1761
|
+
console.print(f"[green]✓[/green] {message}")
|
|
1762
|
+
else:
|
|
1763
|
+
console.print(f"[red]✗[/red] {message}")
|
|
1764
|
+
raise typer.Exit(1)
|
|
1765
|
+
|
|
1766
|
+
else:
|
|
1767
|
+
console.print(f"[red]Unknown action: {action}[/red]")
|
|
1768
|
+
console.print("Available actions: list, create, switch, remove, info, current, rename")
|
|
1769
|
+
raise typer.Exit(1)
|
|
1770
|
+
|
|
1771
|
+
except Exception as e:
|
|
1772
|
+
result = {"success": False, "error": str(e)}
|
|
1773
|
+
if json_output:
|
|
1774
|
+
print(output_json(result))
|
|
1775
|
+
else:
|
|
1776
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
1777
|
+
raise typer.Exit(1)
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
# =============================================================================
|
|
1781
|
+
# 性能优化命令
|
|
1782
|
+
# =============================================================================
|
|
1783
|
+
|
|
1784
|
+
@app.command()
|
|
1785
|
+
def bulk_import(
|
|
1786
|
+
file_path: str = typer.Argument(..., help="JSON 文件路径,包含笔记数据"),
|
|
1787
|
+
note_type: str = typer.Option("permanent", "--type", "-t", help="笔记类型"),
|
|
1788
|
+
batch_size: int = typer.Option(32, "--batch-size", "-b", help="批处理大小"),
|
|
1789
|
+
json_output: bool = typer.Option(True, "--json/--no-json", help="JSON 输出"),
|
|
1790
|
+
):
|
|
1791
|
+
"""
|
|
1792
|
+
批量导入笔记(性能优化版)
|
|
1793
|
+
|
|
1794
|
+
从 JSON 文件批量导入笔记,使用批量嵌入和批量数据库操作。
|
|
1795
|
+
|
|
1796
|
+
JSON 文件格式:
|
|
1797
|
+
[
|
|
1798
|
+
{"title": "笔记1", "content": "内容1", "tags": ["tag1"]},
|
|
1799
|
+
{"title": "笔记2", "content": "内容2"}
|
|
1800
|
+
]
|
|
1801
|
+
|
|
1802
|
+
示例:
|
|
1803
|
+
jfox bulk-import notes.json --type permanent --batch-size 32
|
|
1804
|
+
"""
|
|
1805
|
+
try:
|
|
1806
|
+
import json
|
|
1807
|
+
|
|
1808
|
+
# 读取文件
|
|
1809
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
1810
|
+
notes_data = json.load(f)
|
|
1811
|
+
|
|
1812
|
+
console.print(f"[yellow]Importing {len(notes_data)} notes...[/yellow]")
|
|
1813
|
+
|
|
1814
|
+
# 批量导入
|
|
1815
|
+
result = bulk_import_notes(
|
|
1816
|
+
notes_data=notes_data,
|
|
1817
|
+
note_type=note_type,
|
|
1818
|
+
batch_size=batch_size,
|
|
1819
|
+
show_progress=not json_output
|
|
1820
|
+
)
|
|
1821
|
+
|
|
1822
|
+
if json_output:
|
|
1823
|
+
print(output_json(result))
|
|
1824
|
+
else:
|
|
1825
|
+
console.print(f"[green]✓[/green] Imported: {result['imported']}")
|
|
1826
|
+
console.print(f"[red]✗[/red] Failed: {result['failed']}")
|
|
1827
|
+
console.print(f"Total: {result['total']}")
|
|
1828
|
+
|
|
1829
|
+
except Exception as e:
|
|
1830
|
+
result = {"success": False, "error": str(e)}
|
|
1831
|
+
if json_output:
|
|
1832
|
+
print(output_json(result))
|
|
1833
|
+
else:
|
|
1834
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
1835
|
+
raise typer.Exit(1)
|
|
1836
|
+
|
|
1837
|
+
|
|
1838
|
+
@app.command()
|
|
1839
|
+
def perf(
|
|
1840
|
+
action: str = typer.Argument("report", help="操作: report, clear-cache"),
|
|
1841
|
+
):
|
|
1842
|
+
"""
|
|
1843
|
+
性能工具和报告
|
|
1844
|
+
|
|
1845
|
+
示例:
|
|
1846
|
+
jfox perf report # 显示性能报告
|
|
1847
|
+
jfox perf clear-cache # 清除模型缓存
|
|
1848
|
+
"""
|
|
1849
|
+
try:
|
|
1850
|
+
if action == "report":
|
|
1851
|
+
monitor = get_perf_monitor()
|
|
1852
|
+
report = monitor.report()
|
|
1853
|
+
|
|
1854
|
+
if not report:
|
|
1855
|
+
console.print("[dim]No performance data yet[/dim]")
|
|
1856
|
+
return
|
|
1857
|
+
|
|
1858
|
+
table = Table(title="Performance Report")
|
|
1859
|
+
table.add_column("Operation", style="cyan")
|
|
1860
|
+
table.add_column("Count", justify="right")
|
|
1861
|
+
table.add_column("Avg (s)", justify="right")
|
|
1862
|
+
table.add_column("Total (s)", justify="right")
|
|
1863
|
+
|
|
1864
|
+
for op, stats in sorted(report.items(), key=lambda x: x[1]["total"], reverse=True):
|
|
1865
|
+
table.add_row(
|
|
1866
|
+
op,
|
|
1867
|
+
str(stats["count"]),
|
|
1868
|
+
f"{stats['avg']:.3f}",
|
|
1869
|
+
f"{stats['total']:.3f}",
|
|
1870
|
+
)
|
|
1871
|
+
|
|
1872
|
+
console.print(table)
|
|
1873
|
+
|
|
1874
|
+
elif action == "clear-cache":
|
|
1875
|
+
ModelCache.clear()
|
|
1876
|
+
console.print("[green]✓[/green] Model cache cleared")
|
|
1877
|
+
|
|
1878
|
+
else:
|
|
1879
|
+
console.print(f"[red]Unknown action: {action}[/red]")
|
|
1880
|
+
console.print("Available: report, clear-cache")
|
|
1881
|
+
raise typer.Exit(1)
|
|
1882
|
+
|
|
1883
|
+
except Exception as e:
|
|
1884
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
1885
|
+
raise typer.Exit(1)
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
# 入口点
|
|
1889
|
+
def main():
|
|
1890
|
+
"""CLI 入口点"""
|
|
1891
|
+
app()
|
|
1892
|
+
|
|
1893
|
+
|
|
1894
|
+
if __name__ == "__main__":
|
|
1895
|
+
main()
|