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.
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/PKG-INFO +1 -1
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/pyproject.toml +1 -1
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/cli.py +5 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/crawl.py +16 -1
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/health.py +6 -2
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/index.py +27 -7
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/registration.py +11 -3
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/retrieval.py +64 -7
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/server.py +3 -1
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/PKG-INFO +1 -1
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_health_checks.py +26 -1
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_server_flow.py +2 -1
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_sparse_retrieval.py +54 -2
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/README.md +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/setup.cfg +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/__init__.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/auth.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/client.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/extract.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/extract_files.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/models.lock.json +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/settings.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/update.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/SOURCES.txt +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/dependency_links.txt +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/entry_points.txt +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/requires.txt +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/top_level.txt +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_agent_doc_consistency.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_client_get_only.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_crawl.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_extract.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_index.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_prepare_models.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_registration.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_retrieval.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_secrets.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_stale_model.py +0 -0
- {confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_update.py +0 -0
|
@@ -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
|
|
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}")
|
{confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/health.py
RENAMED
|
@@ -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
|
-
|
|
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:
|
|
165
|
-
k = len(blob) //
|
|
166
|
-
idx = np.frombuffer(blob[:
|
|
167
|
-
vals = np.frombuffer(blob[
|
|
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
|
|
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
|
|
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]:
|
{confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/registration.py
RENAMED
|
@@ -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
|
|
129
|
-
|
|
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
|
-
|
|
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):
|
{confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/retrieval.py
RENAMED
|
@@ -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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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())
|
{confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/server.py
RENAMED
|
@@ -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,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(
|
|
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
|
-
|
|
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(
|
|
78
|
-
assert idx.model_id() ==
|
|
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
|
|
File without changes
|
|
File without changes
|
{confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/client.py
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/extract.py
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/extract_files.py
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/models.lock.json
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/settings.py
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/src/confluence_knowledge/update.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{confluence_knowledge-0.5.1 → confluence_knowledge-0.5.2}/tests/test_agent_doc_consistency.py
RENAMED
|
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
|