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.
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/PKG-INFO +1 -1
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/pyproject.toml +1 -1
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/cli.py +13 -1
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/client.py +2 -1
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/crawl.py +16 -1
- confluence_knowledge-0.5.2/src/confluence_knowledge/health.py +124 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/index.py +45 -8
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/registration.py +11 -3
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/retrieval.py +78 -6
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/server.py +24 -7
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/PKG-INFO +1 -1
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/SOURCES.txt +2 -0
- confluence_knowledge-0.5.2/tests/test_health_checks.py +145 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_server_flow.py +6 -3
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_sparse_retrieval.py +54 -2
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/README.md +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/setup.cfg +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/__init__.py +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/auth.py +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/extract.py +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/extract_files.py +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/models.lock.json +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/settings.py +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/update.py +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/dependency_links.txt +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/entry_points.txt +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/requires.txt +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge.egg-info/top_level.txt +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_agent_doc_consistency.py +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_client_get_only.py +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_crawl.py +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_extract.py +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_index.py +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_prepare_models.py +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_registration.py +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_retrieval.py +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_secrets.py +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_stale_model.py +0 -0
- {confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/tests/test_update.py +0 -0
|
@@ -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
|
{confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/client.py
RENAMED
|
@@ -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) —
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
158
|
-
k = len(blob) //
|
|
159
|
-
idx = np.frombuffer(blob[:
|
|
160
|
-
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)
|
|
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
|
|
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
|
|
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]
|
{confluence_knowledge-0.5.0 → 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.0 → 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
|
|
|
@@ -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
|
-
|
|
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())
|
{confluence_knowledge-0.5.0 → 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,
|
|
@@ -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
|
-
|
|
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
|
-
"
|
|
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(
|
|
185
|
+
"index_root": str(idx_root),
|
|
169
186
|
"live_scopes": sorted(_scopes),
|
|
170
187
|
}
|
|
171
188
|
|
|
@@ -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
|
-
|
|
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
|
|
131
|
-
#
|
|
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(
|
|
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.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/extract.py
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/extract_files.py
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/models.lock.json
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.0 → confluence_knowledge-0.5.2}/src/confluence_knowledge/settings.py
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.0 → 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
|
{confluence_knowledge-0.5.0 → 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
|