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/config.py ADDED
@@ -0,0 +1,180 @@
1
+ """配置管理"""
2
+
3
+ import os
4
+ from contextlib import contextmanager
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import yaml
10
+
11
+
12
+ def get_default_kb_path() -> Path:
13
+ """获取默认知识库路径(从全局配置)"""
14
+ try:
15
+ from .global_config import get_global_config_manager
16
+ return get_global_config_manager().get_default_kb_path()
17
+ except Exception:
18
+ return Path.home() / ".zettelkasten"
19
+
20
+
21
+ @dataclass
22
+ class ZKConfig:
23
+ """Zettelkasten 配置"""
24
+ # 路径
25
+ base_dir: Path = field(default_factory=get_default_kb_path)
26
+ notes_dir: Path = field(init=False)
27
+ zk_dir: Path = field(init=False)
28
+ chroma_dir: Path = field(init=False)
29
+
30
+ # NPU 配置
31
+ embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2"
32
+ embedding_dimension: int = 384
33
+ device: str = "auto" # auto / npu / gpu / cpu
34
+ batch_size: int = 32
35
+
36
+ # 检索配置
37
+ default_semantic_top: int = 5
38
+ default_graph_hops: int = 2
39
+ similarity_threshold: float = 0.7
40
+
41
+ # 同步配置
42
+ auto_sync: bool = True
43
+ sync_interval: int = 30 # 秒
44
+
45
+ def __post_init__(self):
46
+ self.notes_dir = self.base_dir / "notes"
47
+ self.zk_dir = self.base_dir / ".zk"
48
+ self.chroma_dir = self.zk_dir / "chroma_db"
49
+
50
+ def ensure_dirs(self):
51
+ """确保目录存在"""
52
+ dirs = [
53
+ self.notes_dir / "fleeting",
54
+ self.notes_dir / "literature",
55
+ self.notes_dir / "permanent",
56
+ self.zk_dir,
57
+ self.chroma_dir,
58
+ self.zk_dir / "cache",
59
+ ]
60
+ for d in dirs:
61
+ d.mkdir(parents=True, exist_ok=True)
62
+
63
+ def save(self, path: Optional[Path] = None):
64
+ """保存配置到文件"""
65
+ if path is None:
66
+ path = self.zk_dir / "config.yaml"
67
+
68
+ config_dict = {
69
+ "base_dir": str(self.base_dir),
70
+ "embedding_model": self.embedding_model,
71
+ "embedding_dimension": self.embedding_dimension,
72
+ "device": self.device,
73
+ "batch_size": self.batch_size,
74
+ "default_semantic_top": self.default_semantic_top,
75
+ "default_graph_hops": self.default_graph_hops,
76
+ "similarity_threshold": self.similarity_threshold,
77
+ "auto_sync": self.auto_sync,
78
+ "sync_interval": self.sync_interval,
79
+ }
80
+
81
+ with open(path, 'w', encoding='utf-8') as f:
82
+ yaml.dump(config_dict, f, allow_unicode=True, sort_keys=False)
83
+
84
+ @classmethod
85
+ def load(cls, path: Optional[Path] = None) -> "ZKConfig":
86
+ """从文件加载配置"""
87
+ # 首先获取默认知识库路径
88
+ default_path = get_default_kb_path()
89
+
90
+ if path is None:
91
+ path = default_path / ".zk" / "config.yaml"
92
+
93
+ if not path.exists():
94
+ return cls(base_dir=default_path)
95
+
96
+ with open(path, 'r', encoding='utf-8') as f:
97
+ config_dict = yaml.safe_load(f)
98
+
99
+ # 转换路径
100
+ if 'base_dir' in config_dict:
101
+ config_dict['base_dir'] = Path(config_dict['base_dir'])
102
+ else:
103
+ config_dict['base_dir'] = default_path
104
+
105
+ return cls(**config_dict)
106
+
107
+ @classmethod
108
+ def for_kb(cls, kb_path: Path) -> "ZKConfig":
109
+ """为指定知识库创建配置"""
110
+ return cls(base_dir=kb_path)
111
+
112
+
113
+ # 全局配置实例(动态获取当前默认知识库)
114
+ def get_config() -> ZKConfig:
115
+ """获取当前默认知识库的配置"""
116
+ return ZKConfig.load()
117
+
118
+
119
+ # 为了向后兼容,保留 config 变量,但使用动态获取
120
+ config = get_config()
121
+
122
+
123
+ @contextmanager
124
+ def use_kb(kb_name: Optional[str] = None):
125
+ """
126
+ 临时使用指定知识库的上下文管理器
127
+
128
+ 用法:
129
+ with use_kb("work"):
130
+ # 在这个上下文中,操作都在 work 知识库上
131
+ note = create_note(...)
132
+
133
+ Args:
134
+ kb_name: 知识库名称,None 表示使用当前默认知识库
135
+
136
+ Raises:
137
+ ValueError: 知识库不存在
138
+ """
139
+ if not kb_name:
140
+ yield
141
+ return
142
+
143
+ from .kb_manager import get_kb_manager
144
+ manager = get_kb_manager()
145
+
146
+ if not manager.config_manager.kb_exists(kb_name):
147
+ raise ValueError(f"Knowledge base '{kb_name}' not found")
148
+
149
+ # 保存原始配置
150
+ original_base_dir = config.base_dir
151
+ original_notes_dir = config.notes_dir
152
+ original_zk_dir = config.zk_dir
153
+ original_chroma_dir = config.chroma_dir
154
+
155
+ try:
156
+ # 切换到目标知识库
157
+ kb_path = manager.config_manager.get_kb_path(kb_name)
158
+ config.base_dir = kb_path
159
+ config.notes_dir = kb_path / "notes"
160
+ config.zk_dir = kb_path / ".zk"
161
+ config.chroma_dir = config.zk_dir / "chroma_db"
162
+
163
+ # 重置索引和搜索引擎(使用新的知识库路径)
164
+ try:
165
+ from .bm25_index import reset_bm25_index
166
+ from .search_engine import reset_search_engine
167
+ from .vector_store import reset_vector_store
168
+ reset_bm25_index()
169
+ reset_search_engine()
170
+ reset_vector_store()
171
+ except Exception:
172
+ pass # 忽略重置错误
173
+
174
+ yield
175
+ finally:
176
+ # 恢复原始配置
177
+ config.base_dir = original_base_dir
178
+ config.notes_dir = original_notes_dir
179
+ config.zk_dir = original_zk_dir
180
+ config.chroma_dir = original_chroma_dir
@@ -0,0 +1,65 @@
1
+ """Embedding Backend - CPU Only (Simplified)"""
2
+
3
+ import logging
4
+ from typing import List, Optional
5
+ import numpy as np
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class EmbeddingBackend:
11
+ """Simple CPU-based embedding backend"""
12
+
13
+ def __init__(self, model_name: str = "sentence-transformers/all-MiniLM-L6-v2"):
14
+ self.model_name = model_name
15
+ self.model = None
16
+
17
+ def load(self):
18
+ """Load the model"""
19
+ if self.model is not None:
20
+ return
21
+
22
+ try:
23
+ from sentence_transformers import SentenceTransformer
24
+ self.model = SentenceTransformer(self.model_name)
25
+ logger.info(f"Model loaded: {self.model_name}")
26
+ except Exception as e:
27
+ logger.error(f"Failed to load model: {e}")
28
+ raise
29
+
30
+ def encode(self, texts: List[str], batch_size: int = 32) -> np.ndarray:
31
+ """Encode texts to embeddings"""
32
+ if self.model is None:
33
+ self.load()
34
+
35
+ try:
36
+ return self.model.encode(
37
+ texts,
38
+ batch_size=batch_size,
39
+ show_progress_bar=False,
40
+ convert_to_numpy=True
41
+ )
42
+ except Exception as e:
43
+ logger.error(f"Encoding failed: {e}")
44
+ raise
45
+
46
+ def encode_single(self, text: str) -> np.ndarray:
47
+ """Encode a single text"""
48
+ return self.encode([text])[0]
49
+
50
+ @property
51
+ def dimension(self) -> int:
52
+ """Return embedding dimension"""
53
+ return 384
54
+
55
+
56
+ # Global backend instance
57
+ _backend: Optional[EmbeddingBackend] = None
58
+
59
+
60
+ def get_backend() -> EmbeddingBackend:
61
+ """Get global embedding backend instance"""
62
+ global _backend
63
+ if _backend is None:
64
+ _backend = EmbeddingBackend()
65
+ return _backend
jfox/formatters.py ADDED
@@ -0,0 +1,305 @@
1
+ """
2
+ 输出格式化器
3
+
4
+ 支持多种输出格式:json, table, csv, yaml, paths, tree
5
+ """
6
+
7
+ import csv
8
+ import io
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional, Union
12
+
13
+ import yaml
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+ from rich.tree import Tree
17
+
18
+
19
+ class OutputFormatter:
20
+ """输出格式化器"""
21
+
22
+ SUPPORTED_FORMATS = ["json", "table", "csv", "yaml", "paths", "tree"]
23
+
24
+ @classmethod
25
+ def format(cls, data: Any, format_type: str, **kwargs) -> str:
26
+ """
27
+ 根据格式类型格式化数据
28
+
29
+ Args:
30
+ data: 要格式化的数据
31
+ format_type: 格式类型
32
+ **kwargs: 额外参数
33
+
34
+ Returns:
35
+ 格式化后的字符串
36
+
37
+ Raises:
38
+ ValueError: 不支持的格式
39
+ """
40
+ format_type = format_type.lower()
41
+
42
+ if format_type == "json":
43
+ return cls.to_json(data)
44
+ elif format_type == "table":
45
+ return cls.to_table(data, **kwargs)
46
+ elif format_type == "csv":
47
+ return cls.to_csv(data, **kwargs)
48
+ elif format_type == "yaml":
49
+ return cls.to_yaml(data)
50
+ elif format_type == "paths":
51
+ return cls.to_paths(data)
52
+ elif format_type == "tree":
53
+ return cls.to_tree(data, **kwargs)
54
+ else:
55
+ raise ValueError(f"Unsupported format: {format_type}. "
56
+ f"Supported: {', '.join(cls.SUPPORTED_FORMATS)}")
57
+
58
+ @staticmethod
59
+ def to_json(data: Any) -> str:
60
+ """
61
+ JSON 格式
62
+
63
+ Args:
64
+ data: 任意可序列化数据
65
+
66
+ Returns:
67
+ JSON 字符串
68
+ """
69
+ return json.dumps(data, ensure_ascii=False, indent=2, default=str)
70
+
71
+ @staticmethod
72
+ def to_csv(data: List[Dict], headers: Optional[List[str]] = None) -> str:
73
+ """
74
+ CSV 格式
75
+
76
+ Args:
77
+ data: 字典列表
78
+ headers: 自定义表头,None 则使用所有键
79
+
80
+ Returns:
81
+ CSV 字符串
82
+ """
83
+ if not data:
84
+ return ""
85
+
86
+ # 确定表头
87
+ if headers is None:
88
+ if isinstance(data[0], dict):
89
+ headers = list(data[0].keys())
90
+ else:
91
+ return ""
92
+
93
+ # 创建 CSV
94
+ output = io.StringIO()
95
+ writer = csv.DictWriter(
96
+ output,
97
+ fieldnames=headers,
98
+ extrasaction='ignore',
99
+ quoting=csv.QUOTE_MINIMAL
100
+ )
101
+ writer.writeheader()
102
+
103
+ for row in data:
104
+ if isinstance(row, dict):
105
+ # 处理嵌套字典和列表
106
+ flat_row = {}
107
+ for key, value in row.items():
108
+ if isinstance(value, (list, dict)):
109
+ flat_row[key] = json.dumps(value, ensure_ascii=False)
110
+ else:
111
+ flat_row[key] = value
112
+ writer.writerow(flat_row)
113
+
114
+ return output.getvalue()
115
+
116
+ @staticmethod
117
+ def to_yaml(data: Any) -> str:
118
+ """
119
+ YAML 格式
120
+
121
+ Args:
122
+ data: 任意可序列化数据
123
+
124
+ Returns:
125
+ YAML 字符串
126
+ """
127
+ return yaml.dump(
128
+ data,
129
+ allow_unicode=True,
130
+ sort_keys=False,
131
+ default_flow_style=False,
132
+ indent=2
133
+ )
134
+
135
+ @staticmethod
136
+ def to_paths(data: Union[List[Dict], List[Path], List[str]],
137
+ key: str = "filepath") -> str:
138
+ """
139
+ 路径格式 - 每行一个路径
140
+
141
+ Args:
142
+ data: 数据列表
143
+ key: 路径字段名
144
+
145
+ Returns:
146
+ 路径字符串,每行一个
147
+ """
148
+ if not data:
149
+ return ""
150
+
151
+ paths = []
152
+ for item in data:
153
+ if isinstance(item, (str, Path)):
154
+ paths.append(str(item))
155
+ elif isinstance(item, dict):
156
+ path = item.get(key)
157
+ if path:
158
+ paths.append(str(path))
159
+
160
+ return "\n".join(paths)
161
+
162
+ @staticmethod
163
+ def to_table(data: List[Dict],
164
+ columns: Optional[List[str]] = None,
165
+ title: Optional[str] = None) -> str:
166
+ """
167
+ 表格格式(使用 Rich)
168
+
169
+ 注意:此函数返回的字符串需要通过 rich.console 渲染
170
+
171
+ Args:
172
+ data: 字典列表
173
+ columns: 显示的列,None 则显示所有
174
+ title: 表格标题
175
+
176
+ Returns:
177
+ 表格对象(特殊处理,需要渲染)
178
+ """
179
+ if not data:
180
+ return "(No data)"
181
+
182
+ # 确定列
183
+ if columns is None:
184
+ if isinstance(data[0], dict):
185
+ columns = list(data[0].keys())
186
+ else:
187
+ return str(data)
188
+
189
+ # 创建表格
190
+ table = Table(title=title)
191
+ for col in columns:
192
+ table.add_column(str(col), overflow="fold")
193
+
194
+ # 添加行
195
+ for row in data:
196
+ if isinstance(row, dict):
197
+ values = []
198
+ for col in columns:
199
+ val = row.get(col, "")
200
+ # 处理复杂类型
201
+ if isinstance(val, (list, dict)):
202
+ val = json.dumps(val, ensure_ascii=False)[:50]
203
+ elif isinstance(val, Path):
204
+ val = str(val)
205
+ values.append(str(val) if val is not None else "")
206
+ table.add_row(*values)
207
+
208
+ # 渲染为字符串
209
+ console = Console(file=io.StringIO(), force_terminal=False)
210
+ console.print(table)
211
+ return console.file.getvalue()
212
+
213
+ @staticmethod
214
+ def to_tree(data: List[Dict],
215
+ root_name: str = "notes",
216
+ group_by: str = "type") -> str:
217
+ """
218
+ 树形格式
219
+
220
+ Args:
221
+ data: 笔记数据列表
222
+ root_name: 根节点名称
223
+ group_by: 分组字段
224
+
225
+ Returns:
226
+ 树形字符串
227
+ """
228
+ if not data:
229
+ return f"{root_name}/"
230
+
231
+ # 按类型分组
232
+ groups: Dict[str, List[Dict]] = {}
233
+ for item in data:
234
+ if isinstance(item, dict):
235
+ group_key = str(item.get(group_by, "unknown"))
236
+ if group_key not in groups:
237
+ groups[group_key] = []
238
+ groups[group_key].append(item)
239
+
240
+ # 创建树
241
+ tree = Tree(f"[bold]{root_name}/[/bold]")
242
+
243
+ for group_name, items in sorted(groups.items()):
244
+ group_node = tree.add(f"[cyan]{group_name}/[/cyan]")
245
+ for item in items:
246
+ title = item.get("title", "Untitled")
247
+ note_id = item.get("id", "")
248
+ if note_id:
249
+ group_node.add(f"{title} ([dim]{note_id}[/dim])")
250
+ else:
251
+ group_node.add(title)
252
+
253
+ # 渲染为字符串
254
+ console = Console(file=io.StringIO(), force_terminal=False)
255
+ console.print(tree)
256
+ return console.file.getvalue()
257
+
258
+
259
+ def format_output(data: Any,
260
+ format_type: str,
261
+ console: Optional[Console] = None,
262
+ **kwargs) -> None:
263
+ """
264
+ 格式化并输出数据
265
+
266
+ Args:
267
+ data: 要格式化的数据
268
+ format_type: 格式类型
269
+ console: Rich console 实例,None 则创建新实例
270
+ **kwargs: 额外参数传递给 formatter
271
+ """
272
+ if console is None:
273
+ console = Console()
274
+
275
+ if format_type == "table":
276
+ # table 格式需要特殊处理
277
+ output = OutputFormatter.to_table(data, **kwargs)
278
+ if output == "(No data)":
279
+ console.print(output)
280
+ else:
281
+ # 重新渲染表格(因为 to_table 已经返回了渲染后的字符串)
282
+ console.print(output)
283
+ elif format_type == "tree":
284
+ output = OutputFormatter.to_tree(data, **kwargs)
285
+ console.print(output)
286
+ else:
287
+ output = OutputFormatter.format(data, format_type, **kwargs)
288
+ console.print(output)
289
+
290
+
291
+ # 向后兼容:支持 --json 参数
292
+ def is_json_format(json_flag: bool, format_param: Optional[str]) -> bool:
293
+ """
294
+ 判断是否使用 JSON 格式(向后兼容)
295
+
296
+ Args:
297
+ json_flag: --json 标志
298
+ format_param: --format 参数值
299
+
300
+ Returns:
301
+ 是否使用 JSON 格式
302
+ """
303
+ if format_param:
304
+ return format_param.lower() == "json"
305
+ return json_flag