confluence-knowledge 0.5.0__tar.gz → 0.5.2__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 (39) hide show
  1. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/PKG-INFO +1 -1
  2. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/pyproject.toml +1 -1
  3. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/cli.py +13 -1
  4. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/client.py +2 -1
  5. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/crawl.py +16 -1
  6. confluence_knowledge-0.5.2/src/confluence_knowledge/health.py +124 -0
  7. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/index.py +45 -8
  8. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/registration.py +11 -3
  9. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/retrieval.py +78 -6
  10. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/server.py +24 -7
  11. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/PKG-INFO +1 -1
  12. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/SOURCES.txt +2 -0
  13. confluence_knowledge-0.5.2/tests/test_health_checks.py +145 -0
  14. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_server_flow.py +6 -3
  15. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_sparse_retrieval.py +54 -2
  16. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/README.md +0 -0
  17. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/setup.cfg +0 -0
  18. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/__init__.py +0 -0
  19. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/auth.py +0 -0
  20. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/extract.py +0 -0
  21. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/extract_files.py +0 -0
  22. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/models.lock.json +0 -0
  23. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/settings.py +0 -0
  24. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/update.py +0 -0
  25. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/dependency_links.txt +0 -0
  26. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/entry_points.txt +0 -0
  27. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/requires.txt +0 -0
  28. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/top_level.txt +0 -0
  29. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_agent_doc_consistency.py +0 -0
  30. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_client_get_only.py +0 -0
  31. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_crawl.py +0 -0
  32. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_extract.py +0 -0
  33. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_index.py +0 -0
  34. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_prepare_models.py +0 -0
  35. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_registration.py +0 -0
  36. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_retrieval.py +0 -0
  37. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_secrets.py +0 -0
  38. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_stale_model.py +0 -0
  39. {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confluence-knowledge
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Local, GET-only Confluence subtree indexer + grounded-Q&A MCP service
5
5
  License: MIT
6
6
  Project-URL: Repository, https://github.com/nkunal1992/agents
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "confluence-knowledge"
7
- version = "0.5.0"
7
+ version = "0.5.2"
8
8
  description = "Local, GET-only Confluence subtree indexer + grounded-Q&A MCP service"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -98,6 +98,13 @@ def _doctor() -> int:
98
98
  try:
99
99
  s = status()
100
100
  print(json.dumps(s, indent=2))
101
+ bad = [c for c in s.get("checks", []) if not c["ok"]]
102
+ if bad:
103
+ print("\nhealth flags:")
104
+ for c in bad:
105
+ print(f" ✗ {c['flag']}: {c['detail']}")
106
+ if c.get("remediation"):
107
+ print(f" → {c['remediation']}")
101
108
  upd = check("confluence-knowledge")
102
109
  if upd["installed"]:
103
110
  line = f"version: {upd['installed']}"
@@ -122,7 +129,7 @@ def _doctor() -> int:
122
129
  except (AccessError, SetupError) as e:
123
130
  print(f"heartbeat: FAILED — {e}", file=sys.stderr)
124
131
  return 3
125
- return 0
132
+ return 4 if bad else 0
126
133
  except SetupError as e:
127
134
  print(f"setup error: {e}", file=sys.stderr)
128
135
  return 2
@@ -162,6 +169,11 @@ def main() -> None:
162
169
  import subprocess
163
170
  cmd = [sys.executable, "-m", "pip", "install", "-U",
164
171
  "confluence-knowledge[models]"]
172
+ # a running MCP server holds open files — on Windows pip can't
173
+ # replace them and on any OS the old code keeps serving until the
174
+ # IDE restarts. Say it plainly before doing anything.
175
+ print("note: quit the IDE (or stop the MCP server) before updating —"
176
+ " a running server can't be replaced cleanly", flush=True)
165
177
  if os.name == "nt":
166
178
  # this exe is the running process on Windows — pip can't replace
167
179
  # a file that's in use; hand the user the equivalent python -m
@@ -114,7 +114,8 @@ class ConfluenceClient:
114
114
  def _describe(resp: httpx.Response) -> str:
115
115
  s = resp.status_code
116
116
  return {
117
- 401: "authentication failed (401) — check the token and account",
117
+ 401: "authentication failed (401) — re-run `setup` to rotate "
118
+ "the credential",
118
119
  403: "permission denied (403) — your account can't view this content",
119
120
  404: "not found (404) — check the page ID/space and base URL",
120
121
  429: "rate limited (429) — retry after the server's Retry-After",
@@ -111,7 +111,7 @@ def _fetch_page_dc(client: ConfluenceClient, page_id: str) -> dict:
111
111
  }
112
112
 
113
113
 
114
- from .retrieval import EMBED_MODEL_ID as _MODEL_ID
114
+ from .retrieval import MODEL_MARKER as _MODEL_ID
115
115
 
116
116
 
117
117
  class Crawler:
@@ -172,6 +172,21 @@ class Crawler:
172
172
 
173
173
  queue: list[str] = job["queue"]
174
174
  errors: list[str] = job["errors"]
175
+
176
+ # Mid-crawl model change: already-embedded pages live in the OLD
177
+ # vector space — finishing this job would publish a mixed-dimension
178
+ # index. Wipe the scope and fail the job; start_crawl rebuilds from
179
+ # roots under the new model.
180
+ stamped = self.index.model_id()
181
+ if stamped is not None and stamped != _MODEL_ID and queue:
182
+ self.index.clear_scope()
183
+ errors = errors + [
184
+ (f"model changed mid-crawl ({stamped} → {_MODEL_ID}) — "
185
+ "index cleared; run start_crawl")]
186
+ self.index.save_job(job_id, "error", [], errors)
187
+ return {"job_id": job_id, "state": "error",
188
+ "remaining": 0, "errors": errors}
189
+
175
190
  seen_raw = self.index.meta_get(f"seen:{job_id}")
176
191
  seen: set[str] = set(json.loads(seen_raw)) if seen_raw else set()
177
192
  levels_raw = self.index.meta_get(f"levels:{job_id}")
@@ -0,0 +1,124 @@
1
+ """Machine-readable health checks for doctor/status — detection only.
2
+
3
+ Every check is {flag, ok, detail, remediation}; the agent turns a red flag
4
+ into exactly one fix command (see the agent-file remediation table).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import shutil
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from .settings import SetupError
15
+
16
+ _DEPS = ("onnxruntime", "tokenizers", "fastembed", "huggingface_hub",
17
+ "numpy", "mcp")
18
+
19
+
20
+ def _dep_ok() -> tuple[bool, list[str]]:
21
+ import importlib.util
22
+ missing = [m for m in _DEPS if importlib.util.find_spec(m) is None]
23
+ return not missing, missing
24
+
25
+
26
+ def _models_checks(home: Path, cli: str) -> list[dict]:
27
+ from .retrieval import models_complete
28
+ out = []
29
+ try:
30
+ mc = models_complete(home)
31
+ except SetupError as e:
32
+ return [{"flag": "models_complete", "ok": False, "detail": str(e),
33
+ "remediation": f"{cli} prepare-models"}]
34
+ for key, spec in mc.items():
35
+ out.append({
36
+ "flag": f"model_{key}", "ok": spec["ok"],
37
+ "detail": ("missing: " + ", ".join(spec["missing"])
38
+ if spec["missing"] else "present"),
39
+ "remediation": None if spec["ok"] else f"{cli} prepare-models"})
40
+ return out
41
+
42
+
43
+ def _index_checks(index_root: Path) -> list[dict]:
44
+ """Per-scope index: integrity + stale-model marker. Detection only."""
45
+ from .index import Index
46
+ from .retrieval import MODEL_MARKER as EMBED_MODEL_ID
47
+ if not index_root.exists():
48
+ return []
49
+ out = []
50
+ for db in sorted(index_root.glob("*.db")):
51
+ scope = db.stem[:24]
52
+ idx = None
53
+ try:
54
+ idx = Index(db)
55
+ ok = idx.integrity()
56
+ stale = idx.model_id() is not None and \
57
+ idx.model_id() != EMBED_MODEL_ID
58
+ except Exception: # noqa: BLE001 — unreadable = corrupt
59
+ ok, stale = False, False
60
+ finally:
61
+ if idx is not None:
62
+ idx.db.close() # Windows locks the file while a conn is open
63
+ out.append({
64
+ "flag": f"index:{scope}", "ok": ok and not stale,
65
+ "detail": ("corrupt — delete the index and re-crawl" if not ok
66
+ else ("built with an older model — run start_crawl once"
67
+ if stale else "ok")),
68
+ "remediation": None if ok and not stale
69
+ else "start_crawl (rebuilds the index)"})
70
+ return out
71
+
72
+
73
+ def health_checks(home: Path, workdir: Path, server_id: str, cli: str,
74
+ index_root: Path | None = None) -> list[dict]:
75
+ """All non-network checks — the machine-readable half of `status()`."""
76
+ checks = []
77
+
78
+ ok, missing = _dep_ok()
79
+ checks.append({"flag": "deps", "ok": ok,
80
+ "detail": "missing: " + ", ".join(missing) if missing
81
+ else "all importable",
82
+ "remediation": None if ok
83
+ else f"pip install -U {cli}[models]"})
84
+
85
+ checks += _models_checks(home, cli)
86
+
87
+ try:
88
+ from .registration import registered_paths
89
+ regs = registered_paths(server_id=server_id, workspace=workdir)
90
+ ok = bool(regs)
91
+ checks.append({"flag": "registration", "ok": ok,
92
+ "detail": (", ".join(str(p) for p in regs) if regs
93
+ else "no IDE config contains the server"),
94
+ "remediation": None if ok
95
+ else f"{cli} register --ide auto"})
96
+ except Exception as e: # noqa: BLE001
97
+ checks.append({"flag": "registration", "ok": False,
98
+ "detail": str(e), "remediation": f"{cli} register"})
99
+
100
+ if index_root is not None:
101
+ checks += _index_checks(index_root)
102
+
103
+ anchor = home
104
+ while not anchor.exists() and anchor != anchor.parent:
105
+ anchor = anchor.parent
106
+ try:
107
+ free = shutil.disk_usage(anchor).free // (1024 * 1024)
108
+ except OSError:
109
+ free = -1 # can't measure — don't flag
110
+ checks.append({"flag": "disk_free", "ok": free < 0 or free >= 2000,
111
+ "detail": f"{free} MB free",
112
+ "remediation": None if free >= 2000
113
+ else "free ~2 GB for the model download"})
114
+
115
+ checks.append({"flag": "python", "ok": sys.version_info >= (3, 11),
116
+ "detail": sys.version.split()[0],
117
+ "remediation": None if sys.version_info >= (3, 11)
118
+ else "reinstall with Python >=3.11"})
119
+
120
+ proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
121
+ checks.append({"flag": "proxy", "ok": True,
122
+ "detail": proxy or "none set",
123
+ "remediation": None})
124
+ return checks
@@ -43,7 +43,18 @@ class Index:
43
43
  # checkpoint) instead of failing instantly; check_same_thread: the
44
44
  # crawl worker and tool-call threads share this one connection —
45
45
  # `self.lock` serializes every access so writes never interleave
46
- self.db = sqlite3.connect(path, timeout=30, check_same_thread=False)
46
+ try:
47
+ self.db = sqlite3.connect(path, timeout=30, check_same_thread=False)
48
+ self.db.execute("SELECT name FROM sqlite_master LIMIT 1").fetchone()
49
+ except sqlite3.DatabaseError as e:
50
+ # close before raising — Windows keeps the file locked otherwise
51
+ from contextlib import suppress
52
+ with suppress(Exception):
53
+ self.db.close()
54
+ from .settings import SetupError
55
+ raise SetupError(
56
+ f"index unreadable at {path} — delete it and re-crawl "
57
+ "(or run `clean` to drop every index)") from e
47
58
  self.lock = threading.RLock()
48
59
  with self.lock:
49
60
  self.db.executescript(SCHEMA)
@@ -138,7 +149,8 @@ class Index:
138
149
  "INSERT INTO vectors VALUES (?,?)",
139
150
  (c.chunk_id, vecs[i].astype(np.float32).tobytes()))
140
151
  if sparse is not None and i < len(sparse) and sparse[i]:
141
- idx_arr = np.fromiter(sparse[i].keys(), dtype=np.int32)
152
+ # bm25 ids are int64 — int32 silently wraps high hashes
153
+ idx_arr = np.fromiter(sparse[i].keys(), dtype=np.int64)
142
154
  val_arr = np.fromiter(sparse[i].values(), dtype=np.float32)
143
155
  self.db.execute(
144
156
  "INSERT INTO sparse_vectors VALUES (?,?)",
@@ -154,16 +166,29 @@ class Index:
154
166
  for cid, blob in self.db.execute(
155
167
  f"SELECT chunk_id,payload FROM sparse_vectors "
156
168
  f"WHERE chunk_id IN ({q})", chunk_ids):
157
- # layout: int32[n] indices + float32[n] values → n = len/8
158
- k = len(blob) // 8
159
- idx = np.frombuffer(blob[:4 * k], dtype=np.int32)
160
- vals = np.frombuffer(blob[4 * k:], dtype=np.float32)
169
+ # layout: int64[n] indices + float32[n] values → n = len/12
170
+ k = len(blob) // 12
171
+ idx = np.frombuffer(blob[:8 * k], dtype=np.int64)
172
+ vals = np.frombuffer(blob[8 * k:], dtype=np.float32)
161
173
  out[cid] = dict(zip(idx.tolist(), vals.tolist()))
162
174
  return out
163
175
 
164
176
  def model_id(self) -> str | None:
165
177
  return self.meta_get("embed_model")
166
178
 
179
+ def clear_scope(self) -> None:
180
+ """Drop all indexed content (pages/chunks/vectors) + the model
181
+ marker. Job rows are kept for history. Used when a model change
182
+ makes the existing vector space invalid."""
183
+ with self.lock:
184
+ for t in ("pages", "chunks", "chunks_fts", "vectors",
185
+ "sparse_vectors"):
186
+ self.db.execute(f"DELETE FROM {t}")
187
+ self.db.execute(
188
+ "DELETE FROM meta WHERE key='embed_model' OR key LIKE 'seen:%'"
189
+ " OR key LIKE 'levels:%'")
190
+ self.db.commit()
191
+
167
192
  def set_model_id(self, model_id: str) -> None:
168
193
  with self.lock, self.db:
169
194
  self.db.execute("INSERT OR REPLACE INTO meta VALUES ('embed_model', ?)",
@@ -194,7 +219,8 @@ class Index:
194
219
  for r in self.db.execute(
195
220
  "SELECT chunk_id,page_id,heading,kind,text FROM chunks WHERE active=1")]
196
221
 
197
- def fts_search(self, query: str, limit: int = 30) -> list[tuple[str, float]]:
222
+ def fts_search(self, query: str, limit: int = 30,
223
+ active: set[str] | None = None) -> list[tuple[str, float]]:
198
224
  with self.lock:
199
225
  try:
200
226
  rows = self.db.execute(
@@ -205,7 +231,8 @@ class Index:
205
231
  limit)).fetchall()
206
232
  except sqlite3.OperationalError:
207
233
  return []
208
- active = {c["chunk_id"] for c in self.active_chunks()}
234
+ if active is None:
235
+ active = {c["chunk_id"] for c in self.active_chunks()}
209
236
  return [(r[0], -r[1]) for r in rows if r[0] in active]
210
237
 
211
238
  def vectors_for(self, chunk_ids: list[str]) -> dict[str, np.ndarray]:
@@ -218,6 +245,16 @@ class Index:
218
245
  f"SELECT chunk_id,vec FROM vectors WHERE chunk_id IN ({q})",
219
246
  chunk_ids)}
220
247
 
248
+ def integrity(self) -> bool:
249
+ """PRAGMA integrity_check — False means the DB file is damaged and
250
+ should be deleted + rebuilt (never auto-repaired here)."""
251
+ try:
252
+ with self.lock:
253
+ row = self.db.execute("PRAGMA integrity_check").fetchone()
254
+ return bool(row and row[0] == "ok")
255
+ except sqlite3.DatabaseError:
256
+ return False
257
+
221
258
  def stats(self) -> dict:
222
259
  with self.lock:
223
260
  p = self.db.execute("SELECT COUNT(*) FROM pages WHERE active=1").fetchone()[0]
@@ -125,10 +125,18 @@ def register(ide: str = "auto", workspace: Path | None = None,
125
125
  return [(f, _merge(f, entry, server_id)) for f in files]
126
126
 
127
127
 
128
- def registered_paths(server_id: str = SERVER_ID) -> list[Path]:
129
- """Existing config files that already contain our server — for doctor."""
128
+ def registered_paths(server_id: str = SERVER_ID,
129
+ workspace: Path | None = None) -> list[Path]:
130
+ """Existing config files that already contain our server — for doctor.
131
+ `workspace` also probes workspace-scoped mcp.json files (a
132
+ `register --ide vscode --workspace <repo>` leaves no user-level trace)."""
130
133
  out = []
131
- for p in targets("all"):
134
+ probes = targets("all")
135
+ if workspace:
136
+ probes += [_workspace_mcp(p) for p in
137
+ {workspace, workspace / ".vscode" if workspace.name != ".vscode"
138
+ else workspace.parent}]
139
+ for p in dict.fromkeys(q for q in probes if q):
132
140
  try:
133
141
  servers = json.loads(p.read_text()).get("servers", {})
134
142
  except (OSError, json.JSONDecodeError, UnicodeDecodeError):
@@ -33,10 +33,14 @@ RERANK_ONNX = "onnx/model_quantized.onnx"
33
33
  RERANK_BASE_REPO = "onnx-community/bge-reranker-base-ONNX" # light override
34
34
 
35
35
  EMBED_MODEL_ID = "bge-m3-onnx-quantized"
36
+ SPARSE_MODEL_ID = "qdrant-bm25-fastembed"
37
+ MODEL_MARKER = f"{EMBED_MODEL_ID}+{SPARSE_MODEL_ID}"
36
38
 
37
39
  TOP_K_RETRIEVE = 30
38
40
  TOP_K_EVIDENCE = 8
39
41
  RERANK_MAX = 25 # bounds cross-encoder latency on CPU
42
+ MIN_RERANK_SCORE = 0.3 # sigmoid floor — below this a hit is "least bad"
43
+ MAX_SEARCHABLE_CHUNKS = 100_000 # whole-index search is O(n) in RAM
40
44
  EMBED_MAX_TOKENS = 512 # quantized model input cap (chunks stay under)
41
45
  RERANK_MAX_TOKENS = 512
42
46
 
@@ -188,13 +192,21 @@ def prepare_models(home: Path) -> list[str]:
188
192
  spec = lock["models"][key]
189
193
  dest = _models_dir(home) / spec["repo"].split("/")[-1]
190
194
  if dest.exists():
191
- complete = all((dest / f).exists() for f in spec["files"])
192
- if complete:
195
+ files_ok = all((dest / f).exists() for f in spec["files"])
196
+ stamp = dest / ".revision"
197
+ if files_ok and not stamp.exists():
198
+ # pre-stamp install — verify files, then stamp in place
199
+ stamp.write_text(spec["revision"])
200
+ if files_ok and stamp.read_text().strip() == spec["revision"]:
193
201
  out.append(f"already prepared: {spec['repo']}")
194
202
  continue
195
- shutil.rmtree(dest) # partial/interrupted → refetch
203
+ if stamp.exists() and stamp.read_text().strip() != spec["revision"]:
204
+ shutil.rmtree(dest) # wrong revision — untrusted, refetch
205
+ # else: missing files but right revision — snapshot_download
206
+ # resumes/skips valid files; only the gaps re-download
196
207
  snapshot_download(repo_id=spec["repo"], revision=spec["revision"],
197
208
  local_dir=dest, allow_patterns=spec["files"])
209
+ (dest / ".revision").write_text(spec["revision"])
198
210
  if not _license_ok(dest):
199
211
  _license_ok_upstream(dest, spec, snapshot_download)
200
212
  if not _license_ok(dest):
@@ -212,6 +224,16 @@ def prepare_models(home: Path) -> list[str]:
212
224
  out.append(f"prepared {spec['repo']} ({spec['license']})")
213
225
  except Exception as e:
214
226
  raise SetupError(f"couldn't fetch sparse model {spec['repo']}: {e}") from e
227
+
228
+ # GC: remove model dirs not owned by the current lock — superseded
229
+ # revisions and legacy models (e.g. the old MiniLM stack) otherwise
230
+ # accumulate ~600 MB each in the user's home.
231
+ owned = {lock["models"][k]["repo"].split("/")[-1]
232
+ for k in ("embed", "rerank", "rerank_light")} | {"fastembed"}
233
+ for d in models.iterdir():
234
+ if d.is_dir() and d.name not in owned:
235
+ shutil.rmtree(d, ignore_errors=True)
236
+ out.append(f"removed stale model dir: {d.name}")
215
237
  return out
216
238
 
217
239
 
@@ -238,6 +260,27 @@ def _license_ok_upstream(dest: Path, spec: dict, snapshot_download) -> None:
238
260
  return
239
261
 
240
262
 
263
+ def models_complete(home: Path) -> dict:
264
+ """Per-model completeness vs the lock — a dir that exists but lacks a
265
+ locked file is a partial download, not a prepared model."""
266
+ lock = _load_lock()["models"]
267
+ out = {}
268
+ for key in ("embed", "rerank", "rerank_light"):
269
+ spec = lock[key]
270
+ dest = _models_dir(home) / spec["repo"].split("/")[-1]
271
+ missing = [f for f in spec["files"] if not (dest / f).exists()]
272
+ wrong_rev = (dest.exists() and not missing
273
+ and (dest / ".revision").exists()
274
+ and (dest / ".revision").read_text().strip()
275
+ != spec["revision"])
276
+ out[key] = {"ok": not missing and not wrong_rev,
277
+ "missing": missing or (["<wrong revision>"]
278
+ if wrong_rev else [])}
279
+ fe = _models_dir(home) / "fastembed"
280
+ out["sparse"] = {"ok": fe.exists() and any(fe.iterdir()), "missing": []}
281
+ return out
282
+
283
+
241
284
  def _license_ok(dest: Path) -> bool:
242
285
  for md in list(dest.glob("*.md")) + list(dest.glob("LICENSE*")):
243
286
  head = md.read_text(errors="ignore")[:6000].lower()
@@ -260,6 +303,12 @@ def _cosine(q: np.ndarray, mat: dict[str, np.ndarray]) -> list[tuple[str, float]
260
303
  if not mat:
261
304
  return []
262
305
  ids = list(mat)
306
+ # a model swap can leave mismatched-dim blobs — skip, never crash
307
+ good = [i for i in ids if mat[i].shape == q.shape]
308
+ if len(good) < len(ids):
309
+ ids = good
310
+ if not ids:
311
+ return []
263
312
  m = np.stack([mat[i] for i in ids])
264
313
  m = m / (np.linalg.norm(m, axis=1, keepdims=True) + 1e-9)
265
314
  q = q / (np.linalg.norm(q) + 1e-9)
@@ -284,14 +333,22 @@ def retrieve(index: Index, query: str, embedder: LocalEmbedder,
284
333
  chunks = index.active_chunks()
285
334
  if not chunks:
286
335
  return []
336
+ if len(chunks) > MAX_SEARCHABLE_CHUNKS:
337
+ from .settings import SetupError
338
+ raise SetupError(
339
+ f"index holds {len(chunks):,} chunks — searching the whole scope "
340
+ "at once is unsupported. Connect a narrower scope (or re-index "
341
+ "with a smaller root).")
287
342
 
288
343
  # dense leg
289
344
  qvec = embedder.embed([query])[0]
290
345
  vecs = index.vectors_for([c["chunk_id"] for c in chunks])
291
346
  dense_rank = [cid for cid, _ in _cosine(qvec, vecs)][:TOP_K_RETRIEVE]
292
347
 
293
- # keyword leg
294
- fts_rank = [cid for cid, _ in index.fts_search(query)][:TOP_K_RETRIEVE]
348
+ # keyword leg — reuse the fetched set; fts_search would re-load it
349
+ active_ids = {c["chunk_id"] for c in chunks}
350
+ fts_rank = [cid for cid, _ in
351
+ index.fts_search(query, active=active_ids)][:TOP_K_RETRIEVE]
295
352
 
296
353
  # sparse leg (optional — learned term weights)
297
354
  sparse_rank: list[str] = []
@@ -316,6 +373,10 @@ def retrieve(index: Index, query: str, embedder: LocalEmbedder,
316
373
  order = np.argsort(-scores)[:TOP_K_EVIDENCE]
317
374
  evidence = []
318
375
  for i in order:
376
+ if scores[i] < MIN_RERANK_SCORE:
377
+ # below the floor the reranker is only ranking "least bad" —
378
+ # surfacing it would ground the answer on noise
379
+ continue
319
380
  c = dict(scored[i])
320
381
  c["rerank_score"] = float(scores[i])
321
382
  evidence.append(c)
@@ -341,7 +402,18 @@ def check_citations(evidence: list[dict], mappings: list[dict]) -> dict:
341
402
  bad.append({"claim": m.get("claim", "")[:80], "reason": "chunk not in evidence set"})
342
403
  continue
343
404
  quote = m.get("quote", "")
344
- if quote and quote not in ch["text"]:
405
+ if quote and _norm(quote) not in _norm(ch["text"]):
345
406
  bad.append({"claim": m.get("claim", "")[:80],
346
407
  "reason": "quote not present verbatim in cited chunk"})
347
408
  return {"ok": not bad, "violations": bad}
409
+
410
+
411
+ def _norm(s: str) -> str:
412
+ """Typography-insensitive form for citation matching — docx extraction
413
+ leaves smart quotes/NBSP/dashes that shouldn't fail a true quote."""
414
+ import unicodedata
415
+ s = unicodedata.normalize("NFKC", s)
416
+ for a, b in (("‘", "'"), ("’", "'"), ("“", '"'), ("”", '"'),
417
+ ("–", "-"), ("—", "-"), (" ", " "), ("−", "-")):
418
+ s = s.replace(a, b)
419
+ return " ".join(s.split())
@@ -18,7 +18,9 @@ from .client import AccessError, ConfluenceClient, SecurityException, make_clien
18
18
  from .crawl import Crawler
19
19
  from .index import Index
20
20
  from .retrieval import (
21
- EMBED_MODEL_ID,
21
+ MODEL_MARKER as EMBED_MODEL_ID,
22
+ )
23
+ from .retrieval import (
22
24
  LocalEmbedder,
23
25
  LocalReranker,
24
26
  LocalSparseEmbedder,
@@ -156,16 +158,31 @@ def status() -> dict:
156
158
  cfg = {"env": profile.env, "site": profile.base_url}
157
159
  except SetupError as e:
158
160
  cfg = {"error": str(e)}
159
- models = home / "models"
161
+ from .health import health_checks
162
+ from .registration import SERVER_ID
163
+ idx_root = workspace_index_root(_workdir())
164
+ checks = health_checks(home, _workdir(), SERVER_ID, "confluence-knowledge",
165
+ index_root=idx_root)
166
+ if "error" not in cfg:
167
+ # cheap liveness: a 401 here means the saved token died — point at
168
+ # setup, not at the docs
169
+ try:
170
+ client = _client_for(profile)
171
+ ep = ("wiki/rest/api/user/current" if profile.env == "cloud"
172
+ else "rest/api/user/current")
173
+ client.get(ep)
174
+ ok, detail = True, "token valid"
175
+ except (AccessError, SetupError) as e:
176
+ ok, detail = False, str(e)
177
+ checks.append({"flag": "creds_valid", "ok": ok, "detail": detail,
178
+ "remediation": None if ok else
179
+ "confluence-knowledge setup (rotate the token)"})
160
180
  return {
161
181
  "configured": "error" not in cfg,
162
182
  "profile": cfg,
163
- "embedding_model_ready": (models / "bge-m3").exists(),
164
- "reranker_model_ready": (models / "bge-reranker-v2-m3-ONNX").exists()
165
- or (models / "bge-reranker-base-ONNX").exists(),
166
- "sparse_model_ready": (models / "fastembed").exists(),
183
+ "checks": checks,
167
184
  "models_loaded": _models_ready.is_set() and _EMBEDDER is not None,
168
- "index_root": str(workspace_index_root(_workdir())),
185
+ "index_root": str(idx_root),
169
186
  "live_scopes": sorted(_scopes),
170
187
  }
171
188
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confluence-knowledge
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Local, GET-only Confluence subtree indexer + grounded-Q&A MCP service
5
5
  License: MIT
6
6
  Project-URL: Repository, https://github.com/nkunal1992/agents
@@ -7,6 +7,7 @@ src/confluence_knowledge/client.py
7
7
  src/confluence_knowledge/crawl.py
8
8
  src/confluence_knowledge/extract.py
9
9
  src/confluence_knowledge/extract_files.py
10
+ src/confluence_knowledge/health.py
10
11
  src/confluence_knowledge/index.py
11
12
  src/confluence_knowledge/models.lock.json
12
13
  src/confluence_knowledge/registration.py
@@ -24,6 +25,7 @@ tests/test_agent_doc_consistency.py
24
25
  tests/test_client_get_only.py
25
26
  tests/test_crawl.py
26
27
  tests/test_extract.py
28
+ tests/test_health_checks.py
27
29
  tests/test_index.py
28
30
  tests/test_prepare_models.py
29
31
  tests/test_registration.py
@@ -0,0 +1,145 @@
1
+ """health_checks flags: each failure mode → exactly one remediation."""
2
+
3
+
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from confluence_knowledge.health import health_checks
9
+ from confluence_knowledge.index import Index
10
+ from confluence_knowledge.retrieval import MODEL_MARKER
11
+ from confluence_knowledge.settings import SetupError
12
+
13
+
14
+ def _flag(checks, name):
15
+ return {c["flag"]: c for c in checks}[name]
16
+
17
+
18
+ def test_models_complete_flags_partial_download(tmp_path, monkeypatch):
19
+ home = tmp_path / "home"
20
+ # partial bge-m3: dir exists, onnx missing — the old shallow check
21
+ # reported this as "ready"
22
+ (home / "models" / "bge-m3").mkdir(parents=True)
23
+ (home / "models" / "bge-m3" / "tokenizer.json").write_text("{}")
24
+ checks = health_checks(home, tmp_path / "work", "sid", "ck")
25
+ emb = _flag(checks, "model_embed")
26
+ assert emb["ok"] is False and "prepare-models" in emb["remediation"]
27
+
28
+
29
+ def test_models_complete_green_when_full(tmp_path):
30
+ home = tmp_path / "home"
31
+ for name, files in (
32
+ ("bge-m3", ["onnx/sentence_transformers_quantized.onnx",
33
+ "tokenizer.json", "tokenizer_config.json",
34
+ "config.json", "sentencepiece.bpe.model",
35
+ "special_tokens_map.json", "README.md"]),
36
+ ("bge-reranker-v2-m3-ONNX", ["onnx/model_quantized.onnx",
37
+ "tokenizer.json",
38
+ "tokenizer_config.json",
39
+ "config.json",
40
+ "special_tokens_map.json",
41
+ "quantize_config.json", "README.md"]),
42
+ ("bge-reranker-base-ONNX", ["onnx/model_quantized.onnx",
43
+ "tokenizer.json",
44
+ "tokenizer_config.json",
45
+ "config.json",
46
+ "special_tokens_map.json",
47
+ "quantize_config.json", "README.md"])):
48
+ for f in files:
49
+ p = home / "models" / name / f
50
+ p.parent.mkdir(parents=True, exist_ok=True)
51
+ p.write_text("x")
52
+ (home / "models" / "fastembed" / "x").mkdir(parents=True)
53
+ checks = health_checks(home, tmp_path / "work", "sid", "ck")
54
+ for f in ("model_embed", "model_rerank", "model_rerank_light",
55
+ "model_sparse"):
56
+ assert _flag(checks, f)["ok"] is True, f
57
+
58
+
59
+ def test_index_corrupt_flagged(tmp_path):
60
+ idx_root = tmp_path / "idx"
61
+ idx_root.mkdir()
62
+ (idx_root / "scope1.db").write_bytes(b"not a sqlite file" * 100)
63
+ checks = health_checks(tmp_path / "home", tmp_path / "work", "sid", "ck",
64
+ index_root=idx_root)
65
+ f = [c for c in checks if c["flag"].startswith("index:")]
66
+ assert f and f[0]["ok"] is False and "corrupt" in f[0]["detail"]
67
+
68
+
69
+ def test_index_stale_model_flagged(tmp_path):
70
+ idx_root = tmp_path / "idx"
71
+ idx_root.mkdir()
72
+ idx = Index(idx_root / "scope1.db")
73
+ idx.set_model_id("old-model-xyz")
74
+ checks = health_checks(tmp_path / "home", tmp_path / "work", "sid", "ck",
75
+ index_root=idx_root)
76
+ f = [c for c in checks if c["flag"].startswith("index:")]
77
+ assert f and f[0]["ok"] is False and "start_crawl" in f[0]["remediation"]
78
+
79
+
80
+ def test_index_healthy_passes(tmp_path):
81
+ idx_root = tmp_path / "idx"
82
+ idx_root.mkdir()
83
+ idx = Index(idx_root / "scope1.db")
84
+ idx.set_model_id(MODEL_MARKER)
85
+ checks = health_checks(tmp_path / "home", tmp_path / "work", "sid", "ck",
86
+ index_root=idx_root)
87
+ f = [c for c in checks if c["flag"].startswith("index:")]
88
+ assert f and f[0]["ok"] is True
89
+
90
+
91
+ def test_corrupt_db_raises_setup_error_not_sqlite(tmp_path):
92
+ db = tmp_path / "bad.db"
93
+ db.write_bytes(b"garbage" * 1000)
94
+ with pytest.raises(SetupError, match="index unreadable"):
95
+ Index(db)
96
+
97
+
98
+ def test_registration_missing_flagged(tmp_path, monkeypatch):
99
+ monkeypatch.setattr("confluence_knowledge.registration.targets",
100
+ lambda ide, workspace=None: [])
101
+ checks = health_checks(tmp_path / "home", tmp_path / "work", "sid", "ck")
102
+ reg = _flag(checks, "registration")
103
+ assert reg["ok"] is False and "register" in reg["remediation"]
104
+
105
+
106
+ def test_deps_flag_when_missing(tmp_path, monkeypatch):
107
+ import importlib.util
108
+ real = importlib.util.find_spec
109
+ monkeypatch.setattr(importlib.util, "find_spec",
110
+ lambda m: None if m == "fastembed" else real(m))
111
+ checks = health_checks(tmp_path / "home", tmp_path / "work", "sid", "ck")
112
+ assert _flag(checks, "deps")["ok"] is False
113
+
114
+
115
+ def test_every_red_flag_has_a_remediation(tmp_path, monkeypatch):
116
+ """The agent's contract: a red flag never lands without a fix command."""
117
+ monkeypatch.setattr("confluence_knowledge.registration.targets",
118
+ lambda ide, workspace=None: [])
119
+ checks = health_checks(tmp_path / "home", tmp_path / "work", "sid", "ck")
120
+ red = [c for c in checks if c["ok"] is False and c["flag"] != "proxy"]
121
+ assert red # some flags SHOULD be red on a bare tmp home
122
+ for c in red:
123
+ assert c["remediation"], f"red flag {c['flag']} has no remediation"
124
+
125
+
126
+ def test_documented_flags_exist(tmp_path, monkeypatch):
127
+ """Every flag named in the agent's remediation table must be a flag
128
+ doctor can actually emit — drift silently strands users (hunt F)."""
129
+ import re
130
+ monkeypatch.setenv("CONF_KNOWLEDGE_HOME", str(tmp_path))
131
+ monkeypatch.setenv("CONF_KNOWLEDGE_WORKDIR", str(tmp_path / "w"))
132
+ idx_root = tmp_path / "idx"
133
+ idx_root.mkdir()
134
+ Index(idx_root / "scope.db") # so index:* flags are emitted
135
+ checks = health_checks(tmp_path / "home", tmp_path / "w",
136
+ "confluence-knowledge", "confluence-knowledge",
137
+ index_root=idx_root)
138
+ emitted = {c["flag"].split(":")[0] for c in checks}
139
+ emitted.add("creds_valid") # network flag — status() level, not here
140
+ agent = (Path(__file__).parents[3] / "agents"
141
+ / "confluence-knowledge.agent.md").read_text()
142
+ documented = set(re.findall(r"`(deps|model_\w+|registration|index|"
143
+ r"disk_free|python|proxy|creds_valid)", agent))
144
+ missing = documented - emitted
145
+ assert not missing, f"agent documents flags doctor never emits: {missing}"
@@ -100,7 +100,8 @@ class _FakeEmbedder:
100
100
  class _FakeReranker:
101
101
  def score(self, query, docs):
102
102
  import numpy as np
103
- return np.arange(len(docs), dtype=np.float32)
103
+ # descending 0.9, 0.8, … — above the 0.3 evidence floor
104
+ return np.linspace(0.9, 0.5, len(docs), dtype=np.float32)
104
105
 
105
106
 
106
107
  def test_full_agent_flow_with_models(wired, monkeypatch):
@@ -127,9 +128,11 @@ def test_full_agent_flow_with_models(wired, monkeypatch):
127
128
 
128
129
  def test_search_during_crawl_says_crawling(wired, monkeypatch):
129
130
  # keep the worker parked on the model-warmup gate so the job stays
130
- # "running" deterministically — a 1-page crawl finishes before the race
131
- # could otherwise resolve
131
+ # "running" deterministically: a never-set event AND warmup disabled —
132
+ # otherwise the warmup thread sets the event, the worker finishes the
133
+ # 1-page crawl, and search reaches retrieve instead of the early return
132
134
  monkeypatch.setattr(server, "_models_ready", threading.Event())
135
+ monkeypatch.setattr(server, "_start_warmup", lambda: None)
133
136
  c = server.connect("1")
134
137
  server.start_crawl(c["scope_handle"])
135
138
  res = server.search(c["scope_handle"], "text")
@@ -4,7 +4,9 @@ import numpy as np
4
4
  import pytest
5
5
 
6
6
  from confluence_knowledge import retrieval
7
+ from confluence_knowledge.extract import Chunk
7
8
  from confluence_knowledge.index import Index
9
+ from confluence_knowledge.retrieval import MODEL_MARKER
8
10
 
9
11
 
10
12
  class _C:
@@ -74,5 +76,55 @@ def test_sparse_leg_optional(tmp_path):
74
76
  def test_model_marker(tmp_path):
75
77
  idx = Index(tmp_path / "i.db")
76
78
  assert idx.model_id() is None # pre-0.5 index → no marker
77
- idx.set_model_id("bge-m3-onnx-quantized")
78
- assert idx.model_id() == "bge-m3-onnx-quantized"
79
+ idx.set_model_id(MODEL_MARKER)
80
+ assert idx.model_id() == MODEL_MARKER
81
+
82
+
83
+ def test_sparse_id_int64_range(tmp_path):
84
+ """fastembed Qdrant/bm25 emits int64 hashed ids — int32 packing would
85
+ silently wrap ids >2^31 and kill the sparse leg (C-4 hunt bug)."""
86
+ idx = Index(tmp_path / "i.db")
87
+ huge = 2**31 + 12345 # > int32 range — wraps negative if packed as i4
88
+ c = Chunk(text="word", heading_path="", kind="text", ordinal=0,
89
+ chunk_id="c1")
90
+ p = {"page_id": "p", "parent_id": None, "title": "t", "url": "u",
91
+ "level": 0, "version": "", "warnings": ""}
92
+ idx.upsert_page(p, [c], vecs=None, sparse=[{huge: 1.5}])
93
+ idx.publish({"p"})
94
+ got = idx.sparse_for(["c1"])
95
+ assert huge in got["c1"], "sparse id >2^31 must round-trip intact"
96
+ assert got["c1"][huge] == pytest.approx(1.5)
97
+
98
+
99
+ def test_cosine_mixed_dims_skips(tmp_path):
100
+ """A model swap can leave mismatched-dim blobs — retrieval must skip
101
+ them, not crash np.stack (C-1 hunt bug)."""
102
+ q = np.ones(8, dtype=np.float32)
103
+ mat = {"ok": np.ones(8, dtype=np.float32),
104
+ "stale": np.ones(4, dtype=np.float32)}
105
+ ranked = retrieval._cosine(q, mat)
106
+ assert [cid for cid, _ in ranked] == ["ok"]
107
+
108
+
109
+ def test_step_aborts_on_stale_model_mid_crawl(tmp_path):
110
+ """Job checkpointed under an old model must not resume mixing dims —
111
+ scope is wiped and the job errors so start_crawl rebuilds (C-1)."""
112
+ idx = Index(tmp_path / "i.db")
113
+ idx.set_model_id("old-model-marker")
114
+ # a page already indexed under the old model
115
+ c = Chunk(text="stale", heading_path="", kind="text", ordinal=0,
116
+ chunk_id="c1")
117
+ idx.upsert_page({"page_id": "p1", "parent_id": None, "title": "t",
118
+ "url": "u", "level": 0, "version": "", "warnings": ""},
119
+ [c], vecs=None, sparse=None)
120
+ idx.create_job("j1", "root")
121
+ idx.save_job("j1", "running", ["page:2", "page:3"], [])
122
+ idx.db.execute("INSERT OR REPLACE INTO meta VALUES (?,?)", ("seen:j1", '["p1"]')); idx.db.commit()
123
+
124
+ from confluence_knowledge.crawl import Crawler
125
+ cr = Crawler(client=None, index=idx, env="datacenter")
126
+ r = cr.step("j1", batch=None, budget_s=None)
127
+ assert r["state"] == "error"
128
+ assert "model changed" in r["errors"][0]
129
+ assert idx.model_id() is None # marker wiped
130
+ assert idx.active_chunks() == [] # stale content cleared