confluence-knowledge 0.5.1__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.1 → confluence_knowledge-0.5.2}/PKG-INFO +1 -1
  2. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/pyproject.toml +1 -1
  3. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/cli.py +5 -0
  4. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/crawl.py +16 -1
  5. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/health.py +6 -2
  6. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/index.py +27 -7
  7. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/registration.py +11 -3
  8. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/retrieval.py +64 -7
  9. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/server.py +3 -1
  10. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/PKG-INFO +1 -1
  11. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_health_checks.py +26 -1
  12. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_server_flow.py +2 -1
  13. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_sparse_retrieval.py +54 -2
  14. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/README.md +0 -0
  15. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/setup.cfg +0 -0
  16. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/__init__.py +0 -0
  17. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/auth.py +0 -0
  18. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/client.py +0 -0
  19. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/extract.py +0 -0
  20. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/extract_files.py +0 -0
  21. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/models.lock.json +0 -0
  22. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/settings.py +0 -0
  23. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/update.py +0 -0
  24. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/SOURCES.txt +0 -0
  25. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/dependency_links.txt +0 -0
  26. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/entry_points.txt +0 -0
  27. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/requires.txt +0 -0
  28. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/top_level.txt +0 -0
  29. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_agent_doc_consistency.py +0 -0
  30. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_client_get_only.py +0 -0
  31. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_crawl.py +0 -0
  32. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_extract.py +0 -0
  33. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_index.py +0 -0
  34. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_prepare_models.py +0 -0
  35. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_registration.py +0 -0
  36. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_retrieval.py +0 -0
  37. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_secrets.py +0 -0
  38. {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_stale_model.py +0 -0
  39. {confluence_knowledge-0.5.1 → 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.1
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.1"
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"
@@ -169,6 +169,11 @@ def main() -> None:
169
169
  import subprocess
170
170
  cmd = [sys.executable, "-m", "pip", "install", "-U",
171
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)
172
177
  if os.name == "nt":
173
178
  # this exe is the running process on Windows — pip can't replace
174
179
  # a file that's in use; hand the user the equivalent python -m
@@ -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}")
@@ -43,12 +43,13 @@ def _models_checks(home: Path, cli: str) -> list[dict]:
43
43
  def _index_checks(index_root: Path) -> list[dict]:
44
44
  """Per-scope index: integrity + stale-model marker. Detection only."""
45
45
  from .index import Index
46
- from .retrieval import EMBED_MODEL_ID
46
+ from .retrieval import MODEL_MARKER as EMBED_MODEL_ID
47
47
  if not index_root.exists():
48
48
  return []
49
49
  out = []
50
50
  for db in sorted(index_root.glob("*.db")):
51
51
  scope = db.stem[:24]
52
+ idx = None
52
53
  try:
53
54
  idx = Index(db)
54
55
  ok = idx.integrity()
@@ -56,6 +57,9 @@ def _index_checks(index_root: Path) -> list[dict]:
56
57
  idx.model_id() != EMBED_MODEL_ID
57
58
  except Exception: # noqa: BLE001 — unreadable = corrupt
58
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
59
63
  out.append({
60
64
  "flag": f"index:{scope}", "ok": ok and not stale,
61
65
  "detail": ("corrupt — delete the index and re-crawl" if not ok
@@ -82,7 +86,7 @@ def health_checks(home: Path, workdir: Path, server_id: str, cli: str,
82
86
 
83
87
  try:
84
88
  from .registration import registered_paths
85
- regs = registered_paths(server_id=server_id)
89
+ regs = registered_paths(server_id=server_id, workspace=workdir)
86
90
  ok = bool(regs)
87
91
  checks.append({"flag": "registration", "ok": ok,
88
92
  "detail": (", ".join(str(p) for p in regs) if regs
@@ -47,6 +47,10 @@ class Index:
47
47
  self.db = sqlite3.connect(path, timeout=30, check_same_thread=False)
48
48
  self.db.execute("SELECT name FROM sqlite_master LIMIT 1").fetchone()
49
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()
50
54
  from .settings import SetupError
51
55
  raise SetupError(
52
56
  f"index unreadable at {path} — delete it and re-crawl "
@@ -145,7 +149,8 @@ class Index:
145
149
  "INSERT INTO vectors VALUES (?,?)",
146
150
  (c.chunk_id, vecs[i].astype(np.float32).tobytes()))
147
151
  if sparse is not None and i < len(sparse) and sparse[i]:
148
- 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)
149
154
  val_arr = np.fromiter(sparse[i].values(), dtype=np.float32)
150
155
  self.db.execute(
151
156
  "INSERT INTO sparse_vectors VALUES (?,?)",
@@ -161,16 +166,29 @@ class Index:
161
166
  for cid, blob in self.db.execute(
162
167
  f"SELECT chunk_id,payload FROM sparse_vectors "
163
168
  f"WHERE chunk_id IN ({q})", chunk_ids):
164
- # layout: int32[n] indices + float32[n] values → n = len/8
165
- k = len(blob) // 8
166
- idx = np.frombuffer(blob[:4 * k], dtype=np.int32)
167
- 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)
168
173
  out[cid] = dict(zip(idx.tolist(), vals.tolist()))
169
174
  return out
170
175
 
171
176
  def model_id(self) -> str | None:
172
177
  return self.meta_get("embed_model")
173
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
+
174
192
  def set_model_id(self, model_id: str) -> None:
175
193
  with self.lock, self.db:
176
194
  self.db.execute("INSERT OR REPLACE INTO meta VALUES ('embed_model', ?)",
@@ -201,7 +219,8 @@ class Index:
201
219
  for r in self.db.execute(
202
220
  "SELECT chunk_id,page_id,heading,kind,text FROM chunks WHERE active=1")]
203
221
 
204
- 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]]:
205
224
  with self.lock:
206
225
  try:
207
226
  rows = self.db.execute(
@@ -212,7 +231,8 @@ class Index:
212
231
  limit)).fetchall()
213
232
  except sqlite3.OperationalError:
214
233
  return []
215
- 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()}
216
236
  return [(r[0], -r[1]) for r in rows if r[0] in active]
217
237
 
218
238
  def vectors_for(self, chunk_ids: list[str]) -> dict[str, np.ndarray]:
@@ -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
 
@@ -247,7 +269,13 @@ def models_complete(home: Path) -> dict:
247
269
  spec = lock[key]
248
270
  dest = _models_dir(home) / spec["repo"].split("/")[-1]
249
271
  missing = [f for f in spec["files"] if not (dest / f).exists()]
250
- out[key] = {"ok": not missing, "missing": missing}
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 [])}
251
279
  fe = _models_dir(home) / "fastembed"
252
280
  out["sparse"] = {"ok": fe.exists() and any(fe.iterdir()), "missing": []}
253
281
  return out
@@ -275,6 +303,12 @@ def _cosine(q: np.ndarray, mat: dict[str, np.ndarray]) -> list[tuple[str, float]
275
303
  if not mat:
276
304
  return []
277
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 []
278
312
  m = np.stack([mat[i] for i in ids])
279
313
  m = m / (np.linalg.norm(m, axis=1, keepdims=True) + 1e-9)
280
314
  q = q / (np.linalg.norm(q) + 1e-9)
@@ -299,14 +333,22 @@ def retrieve(index: Index, query: str, embedder: LocalEmbedder,
299
333
  chunks = index.active_chunks()
300
334
  if not chunks:
301
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).")
302
342
 
303
343
  # dense leg
304
344
  qvec = embedder.embed([query])[0]
305
345
  vecs = index.vectors_for([c["chunk_id"] for c in chunks])
306
346
  dense_rank = [cid for cid, _ in _cosine(qvec, vecs)][:TOP_K_RETRIEVE]
307
347
 
308
- # keyword leg
309
- 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]
310
352
 
311
353
  # sparse leg (optional — learned term weights)
312
354
  sparse_rank: list[str] = []
@@ -331,6 +373,10 @@ def retrieve(index: Index, query: str, embedder: LocalEmbedder,
331
373
  order = np.argsort(-scores)[:TOP_K_EVIDENCE]
332
374
  evidence = []
333
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
334
380
  c = dict(scored[i])
335
381
  c["rerank_score"] = float(scores[i])
336
382
  evidence.append(c)
@@ -356,7 +402,18 @@ def check_citations(evidence: list[dict], mappings: list[dict]) -> dict:
356
402
  bad.append({"claim": m.get("claim", "")[:80], "reason": "chunk not in evidence set"})
357
403
  continue
358
404
  quote = m.get("quote", "")
359
- if quote and quote not in ch["text"]:
405
+ if quote and _norm(quote) not in _norm(ch["text"]):
360
406
  bad.append({"claim": m.get("claim", "")[:80],
361
407
  "reason": "quote not present verbatim in cited chunk"})
362
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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confluence-knowledge
3
- Version: 0.5.1
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
@@ -1,10 +1,13 @@
1
1
  """health_checks flags: each failure mode → exactly one remediation."""
2
2
 
3
3
 
4
+ from pathlib import Path
5
+
4
6
  import pytest
5
7
 
6
8
  from confluence_knowledge.health import health_checks
7
9
  from confluence_knowledge.index import Index
10
+ from confluence_knowledge.retrieval import MODEL_MARKER
8
11
  from confluence_knowledge.settings import SetupError
9
12
 
10
13
 
@@ -78,7 +81,7 @@ def test_index_healthy_passes(tmp_path):
78
81
  idx_root = tmp_path / "idx"
79
82
  idx_root.mkdir()
80
83
  idx = Index(idx_root / "scope1.db")
81
- idx.set_model_id("bge-m3-onnx-quantized")
84
+ idx.set_model_id(MODEL_MARKER)
82
85
  checks = health_checks(tmp_path / "home", tmp_path / "work", "sid", "ck",
83
86
  index_root=idx_root)
84
87
  f = [c for c in checks if c["flag"].startswith("index:")]
@@ -118,3 +121,25 @@ def test_every_red_flag_has_a_remediation(tmp_path, monkeypatch):
118
121
  assert red # some flags SHOULD be red on a bare tmp home
119
122
  for c in red:
120
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):
@@ -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