jfox-cli 0.4.0__tar.gz → 0.4.1__tar.gz

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.
Files changed (111) hide show
  1. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/PKG-INFO +1 -1
  2. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/__init__.py +1 -1
  3. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/cli.py +4 -0
  4. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/daemon/__init__.py +8 -1
  5. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/daemon/process.py +88 -21
  6. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/indexer.py +2 -2
  7. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/vector_store.py +52 -1
  8. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/pyproject.toml +1 -1
  9. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/test_daemon_process.py +75 -0
  10. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/test_indexer_clear_before_rebuild.py +13 -13
  11. jfox_cli-0.4.1/tests/unit/test_vector_store_clear.py +213 -0
  12. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/uv.lock +1 -1
  13. jfox_cli-0.4.0/tests/unit/test_vector_store_clear.py +0 -76
  14. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/.claude/settings.local.json +0 -0
  15. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/.githooks/pre-push +0 -0
  16. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/.github/workflows/integration-test.yml +0 -0
  17. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/.github/workflows/publish.yml +0 -0
  18. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/.gitignore +0 -0
  19. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/.python-version +0 -0
  20. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/AGENTS.md +0 -0
  21. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/CHANGELOG.md +0 -0
  22. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/CLAUDE.md +0 -0
  23. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/DEVELOPMENT_PLAN.md +0 -0
  24. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/README.md +0 -0
  25. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/SESSION.md +0 -0
  26. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/SESSION_SUMMARY.md +0 -0
  27. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/installation.md +0 -0
  28. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-11-bulk-import-bm25-fix.md +0 -0
  29. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-11-edit-command.md +0 -0
  30. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-11-unify-format-option.md +0 -0
  31. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-12-ci-coverage-optimization.md +0 -0
  32. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-12-edit-content-file.md +0 -0
  33. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-12-fix-index-rebuild-clear.md +0 -0
  34. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-12-fix-index-verify-id-mismatch.md +0 -0
  35. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-12-fix-jfox-health-skill-kb-param.md +0 -0
  36. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-12-index-kb-param.md +0 -0
  37. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-12-lazy-import-perf.md +0 -0
  38. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-12-skill-redesign.md +0 -0
  39. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-14-sync-docs-daemon-show.md +0 -0
  40. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/plans/2026-04-15-session-summary-confirmation.md +0 -0
  41. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/specs/2026-04-03-bugfixes-design.md +0 -0
  42. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/specs/2026-04-12-skill-redesign-design.md +0 -0
  43. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/specs/2026-04-13-pr-auto-code-review-design.md +0 -0
  44. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/specs/2026-04-14-show-command-design.md +0 -0
  45. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/superpowers/specs/2026-04-15-session-summary-confirmation-design.md +0 -0
  46. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/docs/troubleshooting.md +0 -0
  47. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jessica-jones-static-cable.md +0 -0
  48. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/__main__.py +0 -0
  49. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/bm25_index.py +0 -0
  50. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/config.py +0 -0
  51. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/daemon/__main__.py +0 -0
  52. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/daemon/client.py +0 -0
  53. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/daemon/server.py +0 -0
  54. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/embedding_backend.py +0 -0
  55. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/formatters.py +0 -0
  56. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/git_extractor.py +0 -0
  57. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/global_config.py +0 -0
  58. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/graph.py +0 -0
  59. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/kb_manager.py +0 -0
  60. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/models.py +0 -0
  61. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/note.py +0 -0
  62. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/performance.py +0 -0
  63. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/search_engine.py +0 -0
  64. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/template.py +0 -0
  65. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/jfox/template_cli.py +0 -0
  66. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/pytest.ini +0 -0
  67. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/run_full_test.ps1 +0 -0
  68. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/skills-recommend/README.md +0 -0
  69. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/skills-recommend/claude-code/jfox-common/SKILL.md +0 -0
  70. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/skills-recommend/claude-code/jfox-ingest/SKILL.md +0 -0
  71. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/skills-recommend/claude-code/jfox-organize/SKILL.md +0 -0
  72. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/skills-recommend/claude-code/jfox-search/SKILL.md +0 -0
  73. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/skills-recommend/claude-code/jfox-session-summary/SKILL.md +0 -0
  74. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/COVERAGE_PLAN.md +0 -0
  75. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/MIGRATION.md +0 -0
  76. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/TESTS.md +0 -0
  77. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/conftest.py +0 -0
  78. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/integration/__init__.py +0 -0
  79. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/integration/test_backlinks.py +0 -0
  80. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/performance/__init__.py +0 -0
  81. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/performance/test_performance.py +0 -0
  82. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/test_advanced_features.py +0 -0
  83. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/test_cli_format.py +0 -0
  84. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/test_config_set_unit.py +0 -0
  85. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/test_config_unit.py +0 -0
  86. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/test_core_workflow.py +0 -0
  87. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/test_embedding_device.py +0 -0
  88. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/test_hybrid_search.py +0 -0
  89. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/test_integration.py +0 -0
  90. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/test_kb_current.py +0 -0
  91. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/test_suggest_links.py +0 -0
  92. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/__init__.py +0 -0
  93. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/test_bm25_batch.py +0 -0
  94. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/test_edit.py +0 -0
  95. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/test_format_unify.py +0 -0
  96. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/test_formatters.py +0 -0
  97. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/test_git_extractor.py +0 -0
  98. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/test_global_config.py +0 -0
  99. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/test_index_kb_param.py +0 -0
  100. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/test_indexer_verify.py +0 -0
  101. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/test_kb_manager.py +0 -0
  102. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/test_lazy_import.py +0 -0
  103. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/test_logging_config.py +0 -0
  104. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/test_show.py +0 -0
  105. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/test_template.py +0 -0
  106. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/unit/test_template_cli.py +0 -0
  107. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/utils/__init__.py +0 -0
  108. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/utils/assertions.py +0 -0
  109. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/utils/jfox_cli.py +0 -0
  110. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/utils/note_generator.py +0 -0
  111. {jfox_cli-0.4.0 → jfox_cli-0.4.1}/tests/utils/temp_kb.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jfox-cli
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: JFox - Zettelkasten 知识管理 CLI 工具
5
5
  Project-URL: Homepage, https://github.com/zhuxixi/jfox
6
6
  Project-URL: Repository, https://github.com/zhuxixi/jfox
@@ -1,5 +1,5 @@
1
1
  """JFox - Zettelkasten 知识管理工具"""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.1"
4
4
  __author__ = "User"
5
5
  __email__ = "user@example.com"
@@ -2612,7 +2612,10 @@ def daemon(
2612
2612
 
2613
2613
  try:
2614
2614
  if action == "start":
2615
+ from .daemon.process import DAEMON_LOG_FILE
2616
+
2615
2617
  console.print("[yellow]正在启动 embedding daemon...[/yellow]")
2618
+ console.print(f"[dim]日志文件: {DAEMON_LOG_FILE}[/dim]")
2616
2619
  ok = start_daemon(port=port)
2617
2620
  if ok:
2618
2621
  info = get_daemon_status()
@@ -2631,6 +2634,7 @@ def daemon(
2631
2634
  console.print("[green]✓ Daemon 已启动[/green]")
2632
2635
  else:
2633
2636
  console.print("[red]✗ Daemon 启动失败[/red]")
2637
+ console.print(f"[dim]查看日志: {DAEMON_LOG_FILE}[/dim]")
2634
2638
  raise typer.Exit(1)
2635
2639
 
2636
2640
  elif action == "stop":
@@ -3,9 +3,16 @@
3
3
  DEFAULT_HOST = "127.0.0.1"
4
4
  DEFAULT_PORT = 18700
5
5
 
6
- from .process import get_daemon_status, is_daemon_running, start_daemon, stop_daemon
6
+ from .process import (
7
+ DAEMON_LOG_FILE,
8
+ get_daemon_status,
9
+ is_daemon_running,
10
+ start_daemon,
11
+ stop_daemon,
12
+ )
7
13
 
8
14
  __all__ = [
15
+ "DAEMON_LOG_FILE",
9
16
  "DEFAULT_HOST",
10
17
  "DEFAULT_PORT",
11
18
  "start_daemon",
@@ -36,6 +36,8 @@ def _get_pythonw_executable() -> str:
36
36
  logger = logging.getLogger(__name__)
37
37
 
38
38
  STARTUP_TIMEOUT = 60 # 模型加载可能较慢
39
+ FIRST_RUN_TIMEOUT = 300 # 首次下载模型超时(秒)
40
+ DAEMON_LOG_FILE = Path.home() / ".jfox_daemon.log"
39
41
  PID_FILE = Path.home() / ".jfox_daemon.pid"
40
42
 
41
43
 
@@ -103,6 +105,56 @@ def is_daemon_running() -> bool:
103
105
  return False
104
106
 
105
107
 
108
+ def _check_model_cache() -> dict:
109
+ """
110
+ 检查当前模型是否已缓存
111
+
112
+ Returns:
113
+ dict: {"needs_download": bool, "model_name": str, "size_hint": str}
114
+ """
115
+ try:
116
+ from ..config import config as _cfg
117
+ from ..embedding_backend import _CPU_DEFAULT_MODEL, _GPU_DEFAULT_MODEL
118
+
119
+ # 确定目标模型名
120
+ model_name = _cfg.embedding_model
121
+ if model_name == "auto" or not model_name:
122
+ try:
123
+ import torch
124
+
125
+ if torch.cuda.is_available():
126
+ model_name = _GPU_DEFAULT_MODEL
127
+ else:
128
+ model_name = _CPU_DEFAULT_MODEL
129
+ except Exception:
130
+ model_name = _CPU_DEFAULT_MODEL
131
+
132
+ # 检查 HuggingFace 缓存
133
+ hf_home = os.environ.get("HF_HOME", str(Path.home() / ".cache" / "huggingface"))
134
+ hub_cache = os.environ.get("HUGGINGFACE_HUB_CACHE", str(Path(hf_home) / "hub"))
135
+ model_cache_dir = Path(hub_cache) / f"models--{model_name.replace('/', '--')}"
136
+
137
+ size_hint = "2GB" if "bge-m3" in model_name else "90MB"
138
+
139
+ if model_cache_dir.exists():
140
+ snapshots_dir = model_cache_dir / "snapshots"
141
+ has_files = snapshots_dir.exists() and any(snapshots_dir.iterdir())
142
+ return {
143
+ "needs_download": not has_files,
144
+ "model_name": model_name,
145
+ "size_hint": size_hint,
146
+ }
147
+
148
+ return {
149
+ "needs_download": True,
150
+ "model_name": model_name,
151
+ "size_hint": size_hint,
152
+ }
153
+ except Exception as e:
154
+ logger.debug(f"Model cache check failed, assuming download needed: {e}")
155
+ return {"needs_download": True, "model_name": "unknown", "size_hint": ""}
156
+
157
+
106
158
  def start_daemon(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool:
107
159
  """
108
160
  启动 daemon 后台进程
@@ -124,6 +176,15 @@ def start_daemon(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool:
124
176
  )
125
177
  return True
126
178
 
179
+ # 首次启动预检:检查模型缓存是否存在
180
+ timeout = STARTUP_TIMEOUT
181
+ cache_info = _check_model_cache()
182
+ if cache_info["needs_download"]:
183
+ logger.info(
184
+ f"首次启动需要下载模型 {cache_info['model_name']}" f"(约 {cache_info['size_hint']})"
185
+ )
186
+ timeout = FIRST_RUN_TIMEOUT
187
+
127
188
  # 构建启动命令(Windows 使用 pythonw.exe 避免控制台窗口)
128
189
  cmd = [
129
190
  _get_pythonw_executable(),
@@ -137,7 +198,6 @@ def start_daemon(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool:
137
198
 
138
199
  kwargs = {}
139
200
  if sys.platform == "win32":
140
- # Windows: 后台分离进程,不弹窗
141
201
  CREATE_NEW_PROCESS_GROUP = 0x00000200
142
202
  DETACHED_PROCESS = 0x00000008
143
203
  CREATE_NO_WINDOW = 0x08000000
@@ -145,39 +205,46 @@ def start_daemon(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool:
145
205
  else:
146
206
  kwargs["start_new_session"] = True
147
207
 
208
+ # 子进程日志落盘(stdout/stderr → 日志文件)
209
+ log_file = open(DAEMON_LOG_FILE, "a", encoding="utf-8")
210
+
148
211
  try:
149
212
  proc = subprocess.Popen(
150
213
  cmd,
151
- stdout=subprocess.DEVNULL,
152
- stderr=subprocess.DEVNULL,
214
+ stdout=log_file,
215
+ stderr=log_file,
153
216
  stdin=subprocess.DEVNULL,
154
217
  **kwargs,
155
218
  )
156
219
  logger.info(f"Daemon 进程已启动 (PID: {proc.pid})")
220
+ logger.info(f"Daemon 日志文件: {DAEMON_LOG_FILE}")
157
221
  except Exception as e:
222
+ log_file.close()
158
223
  logger.error(f"启动 daemon 失败: {e}")
159
224
  return False
160
225
 
161
226
  # 等待 daemon 就绪(用 HTTP 健康检查判断,不用 PID)
162
- for i in range(STARTUP_TIMEOUT):
163
- time.sleep(1)
164
- health = _http_health_check(host, port)
165
- if health is not None:
166
- # daemon 自身获取真实 PID
167
- real_pid = health.get("pid", proc.pid)
168
- _write_pid_file(
169
- {
170
- "pid": real_pid,
171
- "host": host,
172
- "port": port,
173
- "started_at": time.time(),
174
- }
175
- )
176
- logger.info(f"Daemon 已就绪 (PID: {real_pid}, port: {port})")
177
- return True
227
+ try:
228
+ for i in range(timeout):
229
+ time.sleep(1)
230
+ health = _http_health_check(host, port)
231
+ if health is not None:
232
+ real_pid = health.get("pid", proc.pid)
233
+ _write_pid_file(
234
+ {
235
+ "pid": real_pid,
236
+ "host": host,
237
+ "port": port,
238
+ "started_at": time.time(),
239
+ }
240
+ )
241
+ logger.info(f"Daemon 已就绪 (PID: {real_pid}, port: {port})")
242
+ return True
178
243
 
179
- logger.warning("Daemon 启动超时")
180
- return False
244
+ logger.warning(f"Daemon 启动超时({timeout}秒),日志见 {DAEMON_LOG_FILE}")
245
+ return False
246
+ finally:
247
+ log_file.close()
181
248
 
182
249
 
183
250
  def stop_daemon() -> bool:
@@ -249,8 +249,8 @@ class Indexer:
249
249
  if not notes_dir.exists():
250
250
  return 0
251
251
 
252
- # 清除旧索引数据,确保干净重建
253
- self.vector_store.clear()
252
+ # 彻底重建 collection(删除旧结构 + 重建,解决模型切换后维度不匹配)
253
+ self.vector_store.reset_collection()
254
254
 
255
255
  # Find all note files
256
256
  note_files = list(notes_dir.rglob("*.md"))
@@ -1,6 +1,7 @@
1
1
  """ChromaDB 向量存储封装"""
2
2
 
3
3
  import logging
4
+ import re
4
5
  from pathlib import Path
5
6
  from typing import Any, Dict, List, Optional
6
7
 
@@ -82,7 +83,26 @@ class VectorStore:
82
83
  return True
83
84
 
84
85
  except Exception as e:
85
- logger.error(f"Failed to add note {note.id}: {e}")
86
+ error_msg = str(e)
87
+ if "dimension" in error_msg.lower() and "expecting" in error_msg.lower():
88
+ # 维度不匹配:模型已切换,提示用户 rebuild
89
+ dim_match = re.search(r"dimension of (\d+).*got (\d+)", error_msg, re.IGNORECASE)
90
+ if dim_match:
91
+ old_dim, new_dim = dim_match.group(1), dim_match.group(2)
92
+ logger.error(
93
+ f"Embedding 维度不匹配(collection: {old_dim}, "
94
+ f"当前模型: {new_dim})。"
95
+ f"可能是模型已切换,请执行 jfox index rebuild "
96
+ f"重建索引。原始错误: {error_msg}"
97
+ )
98
+ else:
99
+ logger.error(
100
+ f"Embedding 维度不匹配,可能是模型已切换。"
101
+ f"请执行 jfox index rebuild 重建索引。"
102
+ f"原始错误: {error_msg}"
103
+ )
104
+ else:
105
+ logger.error(f"Failed to add note {note.id}: {error_msg}")
86
106
  return False
87
107
 
88
108
  def search(
@@ -206,6 +226,37 @@ class VectorStore:
206
226
  logger.error(f"Failed to clear vector store: {e}")
207
227
  return False
208
228
 
229
+ def reset_collection(self) -> bool:
230
+ """
231
+ 彻底删除并重建 collection(用于 index rebuild)
232
+
233
+ 与 clear() 不同,reset_collection() 会删除整个 collection 结构再重建,
234
+ 确保 embedding dimension 等元信息也被重置。
235
+ 适用于切换模型后需要 rebuild 的场景。
236
+
237
+ Returns:
238
+ 是否成功重建
239
+ """
240
+ if self.client is None:
241
+ self.init()
242
+
243
+ try:
244
+ self.client.delete_collection("notes")
245
+ logger.info("Deleted old collection 'notes'")
246
+ except ValueError:
247
+ # ChromaDB 对不存在的 collection 抛 ValueError,这是正常情况
248
+ logger.debug("Collection 'notes' did not exist, skipping delete")
249
+
250
+ try:
251
+ self.collection = self.client.get_or_create_collection(
252
+ name="notes", metadata={"hnsw:space": "cosine"}
253
+ )
254
+ logger.info("Recreated collection 'notes'")
255
+ return True
256
+ except Exception as e:
257
+ logger.error(f"Failed to recreate collection: {e}")
258
+ return False
259
+
209
260
 
210
261
  # 全局向量存储实例
211
262
  _vector_store: Optional[VectorStore] = None
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "jfox-cli"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "JFox - Zettelkasten 知识管理 CLI 工具"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -1,6 +1,7 @@
1
1
  """Daemon 进程管理单元测试"""
2
2
 
3
3
  import sys
4
+ from pathlib import Path
4
5
  from unittest.mock import patch
5
6
 
6
7
  import pytest
@@ -84,3 +85,77 @@ class TestStartDaemonWindowsExecutable:
84
85
  flags = kwargs.get("creationflags", 0)
85
86
  CREATE_NO_WINDOW = 0x08000000
86
87
  assert flags & CREATE_NO_WINDOW, "CREATE_NO_WINDOW flag not set"
88
+
89
+
90
+ class TestDaemonLogFile:
91
+ """测试 daemon 子进程日志落盘"""
92
+
93
+ @patch("jfox.daemon.process.subprocess.Popen")
94
+ @patch("jfox.daemon.process._http_health_check")
95
+ def test_start_daemon_writes_to_log_file(self, mock_health, mock_popen):
96
+ """start_daemon 应将子进程 stdout/stderr 重定向到日志文件"""
97
+ import subprocess
98
+
99
+ from jfox.daemon.process import start_daemon
100
+
101
+ mock_health.side_effect = [None, {"pid": 9999}]
102
+ mock_popen.return_value.pid = 1234
103
+
104
+ start_daemon()
105
+
106
+ call_kwargs = mock_popen.call_args[1]
107
+ # stdout 和 stderr 不应是 DEVNULL
108
+ assert call_kwargs["stdout"] != subprocess.DEVNULL
109
+ assert call_kwargs["stderr"] != subprocess.DEVNULL
110
+
111
+ @patch("jfox.daemon.process.subprocess.Popen")
112
+ @patch("jfox.daemon.process._http_health_check")
113
+ def test_daemon_log_file_path(self, mock_health, mock_popen):
114
+ """DAEMON_LOG_FILE 应在用户 home 目录下"""
115
+ from jfox.daemon.process import DAEMON_LOG_FILE
116
+
117
+ assert str(DAEMON_LOG_FILE).endswith(".jfox_daemon.log")
118
+ assert DAEMON_LOG_FILE.parent == Path.home()
119
+
120
+
121
+ class TestDaemonModelCacheCheck:
122
+ """测试模型缓存预检"""
123
+
124
+ def test_check_model_cache_returns_dict(self):
125
+ """_check_model_cache 应返回包含必要键的字典"""
126
+ from jfox.daemon.process import _check_model_cache
127
+
128
+ result = _check_model_cache()
129
+ assert "needs_download" in result
130
+ assert "model_name" in result
131
+ assert "size_hint" in result
132
+ assert isinstance(result["needs_download"], bool)
133
+
134
+ @patch("jfox.daemon.process.os.environ", {})
135
+ def test_first_run_timeout_is_longer(self):
136
+ """FIRST_RUN_TIMEOUT 应大于 STARTUP_TIMEOUT"""
137
+ from jfox.daemon.process import FIRST_RUN_TIMEOUT, STARTUP_TIMEOUT
138
+
139
+ assert FIRST_RUN_TIMEOUT > STARTUP_TIMEOUT
140
+ assert FIRST_RUN_TIMEOUT >= 300
141
+
142
+ @patch("jfox.daemon.process._check_model_cache")
143
+ @patch("jfox.daemon.process.subprocess.Popen")
144
+ @patch("jfox.daemon.process._http_health_check")
145
+ def test_first_run_uses_extended_timeout(self, mock_health, mock_popen, mock_cache):
146
+ """首次下载模型时应使用 FIRST_RUN_TIMEOUT"""
147
+ from jfox.daemon.process import start_daemon
148
+
149
+ mock_health.side_effect = [None, {"pid": 9999}]
150
+ mock_popen.return_value.pid = 1234
151
+ mock_cache.return_value = {
152
+ "needs_download": True,
153
+ "model_name": "BAAI/bge-m3",
154
+ "size_hint": "2GB",
155
+ }
156
+
157
+ start_daemon()
158
+
159
+ # 验证 health check 被调用了足够多次(使用 FIRST_RUN_TIMEOUT)
160
+ # 由于第二次就返回了,至少被调用了 2 次
161
+ assert mock_health.call_count >= 2
@@ -1,7 +1,7 @@
1
1
  """
2
- 测试 Indexer.index_all() 在重建前清除旧数据
2
+ 测试 Indexer.index_all() 在重建前重置 collection
3
3
 
4
- 验证 rebuild 流程:先 clear 再 index
4
+ 验证 rebuild 流程:先 reset_collection 再 index
5
5
  """
6
6
 
7
7
  import tempfile
@@ -9,11 +9,11 @@ from pathlib import Path
9
9
  from unittest.mock import MagicMock, patch
10
10
 
11
11
 
12
- class TestIndexerClearBeforeRebuild:
13
- """Indexer.index_all() 应先清除旧索引再重建"""
12
+ class TestIndexerResetBeforeRebuild:
13
+ """Indexer.index_all() 应先重置 collection 再重建"""
14
14
 
15
- def test_index_all_calls_vector_store_clear(self):
16
- """index_all() 应在索引笔记前调用 vector_store.clear()"""
15
+ def test_index_all_calls_vector_store_reset_collection(self):
16
+ """index_all() 应在索引笔记前调用 vector_store.reset_collection()"""
17
17
  from jfox.indexer import Indexer
18
18
 
19
19
  mock_config = MagicMock()
@@ -29,10 +29,10 @@ class TestIndexerClearBeforeRebuild:
29
29
  count = indexer.index_all()
30
30
 
31
31
  assert count == 0
32
- mock_vector_store.clear.assert_called_once()
32
+ mock_vector_store.reset_collection.assert_called_once()
33
33
 
34
- def test_index_all_clear_before_add(self):
35
- """clear() 必须在 add_or_update_note() 之前调用"""
34
+ def test_index_all_reset_before_add(self):
35
+ """reset_collection() 必须在 add_or_update_note() 之前调用"""
36
36
  from jfox.indexer import Indexer
37
37
 
38
38
  mock_config = MagicMock()
@@ -59,11 +59,11 @@ class TestIndexerClearBeforeRebuild:
59
59
 
60
60
  # 验证调用顺序
61
61
  calls = mock_vector_store.method_calls
62
- clear_indices = [i for i, c in enumerate(calls) if c[0] == "clear"]
62
+ reset_indices = [i for i, c in enumerate(calls) if c[0] == "reset_collection"]
63
63
  add_indices = [i for i, c in enumerate(calls) if c[0] == "add_or_update_note"]
64
64
 
65
- if clear_indices and add_indices:
66
- assert clear_indices[0] < add_indices[0], (
67
- f"clear() (call #{clear_indices[0]}) must be before "
65
+ if reset_indices and add_indices:
66
+ assert reset_indices[0] < add_indices[0], (
67
+ f"reset_collection() (call #{reset_indices[0]}) must be before "
68
68
  f"add_or_update_note() (call #{add_indices[0]})"
69
69
  )
@@ -0,0 +1,213 @@
1
+ """
2
+ 测试 VectorStore.clear() 方法
3
+
4
+ 验证 rebuild 前清除旧数据的逻辑
5
+ """
6
+
7
+ import logging
8
+ from unittest.mock import MagicMock, patch
9
+
10
+ import chromadb
11
+
12
+
13
+ class TestVectorStoreClear:
14
+ """VectorStore.clear() 单元测试"""
15
+
16
+ def test_clear_removes_all_documents(self):
17
+ """clear() 应删除 collection 中所有文档"""
18
+ from jfox.vector_store import VectorStore
19
+
20
+ store = VectorStore()
21
+ # 使用 EphemeralClient 避免文件锁,手动设置 client 和 collection
22
+ client = chromadb.EphemeralClient()
23
+ store.client = client
24
+ store.collection = client.create_collection(
25
+ name="test_clear", metadata={"hnsw:space": "cosine"}
26
+ )
27
+
28
+ # 插入 mock 数据(跳过 embedding,直接操作 collection)
29
+ store.collection.add(
30
+ ids=["note_001", "note_002", "note_003"],
31
+ documents=["doc1", "doc2", "doc3"],
32
+ embeddings=[[0.1] * 384, [0.2] * 384, [0.3] * 384],
33
+ metadatas=[
34
+ {"title": "t1", "type": "permanent", "filepath": "/a", "tags": ""},
35
+ {"title": "t2", "type": "permanent", "filepath": "/b", "tags": ""},
36
+ {"title": "t3", "type": "permanent", "filepath": "/c", "tags": ""},
37
+ ],
38
+ )
39
+
40
+ assert store.collection.count() == 3
41
+
42
+ # 清除
43
+ result = store.clear()
44
+
45
+ assert result is True
46
+ assert store.collection.count() == 0
47
+
48
+ def test_clear_on_empty_collection(self):
49
+ """clear() 在空 collection 上应正常返回 True"""
50
+ from jfox.vector_store import VectorStore
51
+
52
+ store = VectorStore()
53
+ client = chromadb.EphemeralClient()
54
+ store.client = client
55
+ store.collection = client.create_collection(
56
+ name="test_empty", metadata={"hnsw:space": "cosine"}
57
+ )
58
+
59
+ assert store.collection.count() == 0
60
+
61
+ result = store.clear()
62
+
63
+ assert result is True
64
+ assert store.collection.count() == 0
65
+
66
+ def test_clear_returns_false_on_failure(self):
67
+ """clear() 在异常时应返回 False 而非抛出"""
68
+ from jfox.vector_store import VectorStore
69
+
70
+ store = VectorStore()
71
+ # mock 一个会抛异常的 collection
72
+ store.collection = MagicMock()
73
+ store.collection.get.side_effect = Exception("DB error")
74
+
75
+ result = store.clear()
76
+
77
+ assert result is False
78
+
79
+
80
+ class TestVectorStoreResetCollection:
81
+ """VectorStore.reset_collection() 单元测试"""
82
+
83
+ def test_reset_collection_recreates_collection(self):
84
+ """reset_collection() 应删除旧 collection 并创建新的(维度重置)"""
85
+ from jfox.vector_store import VectorStore
86
+
87
+ store = VectorStore()
88
+ client = chromadb.EphemeralClient()
89
+ store.client = client
90
+ store.collection = client.create_collection(name="notes", metadata={"hnsw:space": "cosine"})
91
+
92
+ # 插入 384 维数据
93
+ store.collection.add(
94
+ ids=["note_001"],
95
+ documents=["doc1"],
96
+ embeddings=[[0.1] * 384],
97
+ metadatas=[{"title": "t1", "type": "permanent", "filepath": "/a", "tags": ""}],
98
+ )
99
+ assert store.collection.count() == 1
100
+
101
+ # reset 后 collection 应为空
102
+ result = store.reset_collection()
103
+
104
+ assert result is True
105
+ assert store.collection.count() == 0
106
+
107
+ def test_reset_collection_on_nonexistent_collection(self):
108
+ """reset_collection() 在 collection 不存在时应正常创建新的"""
109
+ from jfox.vector_store import VectorStore
110
+
111
+ store = VectorStore()
112
+ client = chromadb.EphemeralClient()
113
+ store.client = client
114
+ # 不创建 collection,client 上没有 "notes" collection
115
+
116
+ result = store.reset_collection()
117
+
118
+ assert result is True
119
+ assert store.collection is not None
120
+ assert store.collection.count() == 0
121
+
122
+ def test_reset_collection_returns_false_on_recreate_failure(self):
123
+ """get_or_create_collection 失败时返回 False"""
124
+ from jfox.vector_store import VectorStore
125
+
126
+ store = VectorStore()
127
+ store.client = MagicMock()
128
+ store.client.delete_collection.return_value = None
129
+ store.client.get_or_create_collection.side_effect = Exception("DB error")
130
+
131
+ result = store.reset_collection()
132
+
133
+ assert result is False
134
+
135
+ def test_reset_collection_propagates_delete_failure(self):
136
+ """delete_collection 非预期异常应向上冒泡(如文件锁、磁盘错误)"""
137
+ import pytest
138
+
139
+ from jfox.vector_store import VectorStore
140
+
141
+ store = VectorStore()
142
+ store.client = MagicMock()
143
+ # 非 ValueError 的异常(如文件锁)不应被静默吞掉
144
+ store.client.delete_collection.side_effect = RuntimeError("file locked")
145
+
146
+ with pytest.raises(RuntimeError, match="file locked"):
147
+ store.reset_collection()
148
+
149
+
150
+ class TestVectorStoreDimensionMismatch:
151
+ """add_note() 维度不匹配时应给出友好提示"""
152
+
153
+ def test_add_note_dimension_mismatch_friendly_message(self):
154
+ """维度不匹配时 logger.error 应包含 rebuild 提示"""
155
+ from jfox.vector_store import VectorStore
156
+
157
+ store = VectorStore()
158
+ client = chromadb.EphemeralClient()
159
+ store.client = client
160
+ store.collection = client.create_collection(
161
+ name="test_dim_mismatch", metadata={"hnsw:space": "cosine"}
162
+ )
163
+
164
+ # 创建一个假笔记
165
+ note = MagicMock()
166
+ note.id = "20260412120000"
167
+ note.title = "Test"
168
+ note.content = "Test content"
169
+ note.type = MagicMock(value="permanent")
170
+ note.tags = []
171
+ note.filepath = MagicMock()
172
+
173
+ # mock collection.add 抛出维度不匹配异常
174
+ store.collection.add = MagicMock(
175
+ side_effect=Exception("Collection expecting embedding with dimension of 384, got 1024")
176
+ )
177
+
178
+ with patch("jfox.embedding_backend.get_backend") as mock_backend:
179
+ mock_backend.return_value.encode_single.return_value.tolist.return_value = [0.1] * 1024
180
+ with patch.object(logging.getLogger("jfox.vector_store"), "error") as mock_error:
181
+ result = store.add_note(note)
182
+
183
+ assert result is False
184
+ # 验证错误信息包含 rebuild 提示
185
+ error_msg = mock_error.call_args[0][0]
186
+ assert "jfox index rebuild" in error_msg
187
+ assert "384" in error_msg
188
+ assert "1024" in error_msg
189
+
190
+ def test_add_note_non_dimension_exception_unchanged(self):
191
+ """非维度不匹配的异常仍使用原始错误信息格式"""
192
+ from jfox.vector_store import VectorStore
193
+
194
+ store = VectorStore()
195
+ store.collection = MagicMock()
196
+ store.collection.add.side_effect = Exception("Some other error")
197
+
198
+ note = MagicMock()
199
+ note.id = "20260412120000"
200
+ note.title = "Test"
201
+ note.content = "Content"
202
+ note.type = MagicMock(value="permanent")
203
+ note.tags = []
204
+ note.filepath = MagicMock()
205
+
206
+ with patch("jfox.embedding_backend.get_backend") as mock_backend:
207
+ mock_backend.return_value.encode_single.return_value.tolist.return_value = [0.1] * 384
208
+ with patch.object(logging.getLogger("jfox.vector_store"), "error") as mock_error:
209
+ result = store.add_note(note)
210
+
211
+ assert result is False
212
+ error_msg = mock_error.call_args[0][0]
213
+ assert "rebuild" not in error_msg.lower()
@@ -867,7 +867,7 @@ wheels = [
867
867
 
868
868
  [[package]]
869
869
  name = "jfox-cli"
870
- version = "0.4.0"
870
+ version = "0.4.1"
871
871
  source = { editable = "." }
872
872
  dependencies = [
873
873
  { name = "chromadb" },
@@ -1,76 +0,0 @@
1
- """
2
- 测试 VectorStore.clear() 方法
3
-
4
- 验证 rebuild 前清除旧数据的逻辑
5
- """
6
-
7
- from unittest.mock import MagicMock
8
-
9
- import chromadb
10
-
11
-
12
- class TestVectorStoreClear:
13
- """VectorStore.clear() 单元测试"""
14
-
15
- def test_clear_removes_all_documents(self):
16
- """clear() 应删除 collection 中所有文档"""
17
- from jfox.vector_store import VectorStore
18
-
19
- store = VectorStore()
20
- # 使用 EphemeralClient 避免文件锁,手动设置 client 和 collection
21
- client = chromadb.EphemeralClient()
22
- store.client = client
23
- store.collection = client.create_collection(
24
- name="test_clear", metadata={"hnsw:space": "cosine"}
25
- )
26
-
27
- # 插入 mock 数据(跳过 embedding,直接操作 collection)
28
- store.collection.add(
29
- ids=["note_001", "note_002", "note_003"],
30
- documents=["doc1", "doc2", "doc3"],
31
- embeddings=[[0.1] * 384, [0.2] * 384, [0.3] * 384],
32
- metadatas=[
33
- {"title": "t1", "type": "permanent", "filepath": "/a", "tags": ""},
34
- {"title": "t2", "type": "permanent", "filepath": "/b", "tags": ""},
35
- {"title": "t3", "type": "permanent", "filepath": "/c", "tags": ""},
36
- ],
37
- )
38
-
39
- assert store.collection.count() == 3
40
-
41
- # 清除
42
- result = store.clear()
43
-
44
- assert result is True
45
- assert store.collection.count() == 0
46
-
47
- def test_clear_on_empty_collection(self):
48
- """clear() 在空 collection 上应正常返回 True"""
49
- from jfox.vector_store import VectorStore
50
-
51
- store = VectorStore()
52
- client = chromadb.EphemeralClient()
53
- store.client = client
54
- store.collection = client.create_collection(
55
- name="test_empty", metadata={"hnsw:space": "cosine"}
56
- )
57
-
58
- assert store.collection.count() == 0
59
-
60
- result = store.clear()
61
-
62
- assert result is True
63
- assert store.collection.count() == 0
64
-
65
- def test_clear_returns_false_on_failure(self):
66
- """clear() 在异常时应返回 False 而非抛出"""
67
- from jfox.vector_store import VectorStore
68
-
69
- store = VectorStore()
70
- # mock 一个会抛异常的 collection
71
- store.collection = MagicMock()
72
- store.collection.get.side_effect = Exception("DB error")
73
-
74
- result = store.clear()
75
-
76
- assert result is False
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes