confluence-knowledge 0.5.2__tar.gz → 0.5.3__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.2 → confluence_knowledge-0.5.3}/PKG-INFO +1 -1
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/pyproject.toml +1 -1
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/crawl.py +7 -1
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/extract.py +25 -2
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/extract_files.py +9 -4
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/index.py +17 -1
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/retrieval.py +7 -3
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/server.py +13 -7
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge.egg-info/PKG-INFO +1 -1
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/README.md +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/setup.cfg +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/__init__.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/auth.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/cli.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/client.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/health.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/models.lock.json +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/registration.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/settings.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/update.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge.egg-info/SOURCES.txt +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge.egg-info/dependency_links.txt +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge.egg-info/entry_points.txt +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge.egg-info/requires.txt +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge.egg-info/top_level.txt +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_agent_doc_consistency.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_client_get_only.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_crawl.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_extract.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_health_checks.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_index.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_prepare_models.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_registration.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_retrieval.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_secrets.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_server_flow.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_sparse_retrieval.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_stale_model.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_update.py +0 -0
|
@@ -172,6 +172,9 @@ class Crawler:
|
|
|
172
172
|
|
|
173
173
|
queue: list[str] = job["queue"]
|
|
174
174
|
errors: list[str] = job["errors"]
|
|
175
|
+
# pages that fail with deny/gone errors are meant to drop out of the
|
|
176
|
+
# snapshot; transient failures keep their previously-indexed content
|
|
177
|
+
keep_failed: set[str] = set()
|
|
175
178
|
|
|
176
179
|
# Mid-crawl model change: already-embedded pages live in the OLD
|
|
177
180
|
# vector space — finishing this job would publish a mixed-dimension
|
|
@@ -216,6 +219,9 @@ class Crawler:
|
|
|
216
219
|
page = self._page(pid)
|
|
217
220
|
except AccessError as e:
|
|
218
221
|
errors.append(f"page {pid}: {e}")
|
|
222
|
+
if not any(m in str(e) for m in
|
|
223
|
+
("401", "403", "404", "permission", "not found")):
|
|
224
|
+
keep_failed.add(pid)
|
|
219
225
|
continue
|
|
220
226
|
if pid != job["root_page"]:
|
|
221
227
|
anc = page.get("ancestors") or []
|
|
@@ -250,7 +256,7 @@ class Crawler:
|
|
|
250
256
|
self.index.save_job(job_id, "running", queue, errors)
|
|
251
257
|
state = "running"
|
|
252
258
|
else:
|
|
253
|
-
self.index.publish(seen)
|
|
259
|
+
self.index.publish(seen | keep_failed)
|
|
254
260
|
self.index.set_model_id(_MODEL_ID)
|
|
255
261
|
self.index.save_job(job_id, "done", [], errors)
|
|
256
262
|
state = "done"
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/extract.py
RENAMED
|
@@ -38,6 +38,28 @@ def _table_to_text(tbl) -> str:
|
|
|
38
38
|
return "\n".join(out)
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
def split_table_text(t: str) -> list[str]:
|
|
42
|
+
"""An oversized table becomes several ≤MAX_WORDS chunks, each carrying
|
|
43
|
+
the header line — one unbounded chunk would exceed the embedder and the
|
|
44
|
+
reranker's token limits and silently lose its tail rows."""
|
|
45
|
+
if len(t.split()) <= MAX_WORDS:
|
|
46
|
+
return [t]
|
|
47
|
+
lines = t.split("\n")
|
|
48
|
+
header, rows = lines[0], lines[1:]
|
|
49
|
+
hw = len(header.split())
|
|
50
|
+
segs, buf, w = [], [], hw + 2
|
|
51
|
+
for r in rows:
|
|
52
|
+
rw = len(r.split())
|
|
53
|
+
if buf and w + rw > MAX_WORDS:
|
|
54
|
+
segs.append(header + "\n" + "\n".join(buf))
|
|
55
|
+
buf, w = [], hw + 2
|
|
56
|
+
buf.append(r)
|
|
57
|
+
w += rw
|
|
58
|
+
if buf:
|
|
59
|
+
segs.append(header + "\n" + "\n".join(buf))
|
|
60
|
+
return segs
|
|
61
|
+
|
|
62
|
+
|
|
41
63
|
def extract_chunks(storage_html: str) -> tuple[list[Chunk], list[str]]:
|
|
42
64
|
"""Return (chunks, extraction warnings)."""
|
|
43
65
|
soup = BeautifulSoup(storage_html, "html.parser") # stdlib parser; no ext entities
|
|
@@ -110,8 +132,9 @@ def extract_chunks(storage_html: str) -> tuple[list[Chunk], list[str]]:
|
|
|
110
132
|
elif el.name == "table":
|
|
111
133
|
flush()
|
|
112
134
|
t = _table_to_text(el)
|
|
113
|
-
|
|
114
|
-
chunks.append(Chunk(
|
|
135
|
+
for seg in split_table_text(t):
|
|
136
|
+
chunks.append(Chunk(seg, " > ".join(heading_path), "table",
|
|
137
|
+
len(chunks)))
|
|
115
138
|
else:
|
|
116
139
|
buf.append(el.get_text(" ", strip=True))
|
|
117
140
|
flush()
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/extract_files.py
RENAMED
|
@@ -12,7 +12,7 @@ import csv
|
|
|
12
12
|
import io
|
|
13
13
|
import zipfile
|
|
14
14
|
|
|
15
|
-
from .extract import Chunk, extract_chunks
|
|
15
|
+
from .extract import Chunk, extract_chunks, split_table_text
|
|
16
16
|
from .settings import SetupError
|
|
17
17
|
|
|
18
18
|
MAX_FILE_WORDS = 200_000 # ~1MB of text — beyond that, index metadata only
|
|
@@ -111,7 +111,9 @@ def _docx_chunks(data: bytes) -> list[Chunk]:
|
|
|
111
111
|
header, *body = rows
|
|
112
112
|
t = "\n".join([f"Table — columns: {header}"]
|
|
113
113
|
+ [f"row {i}: {r}" for i, r in enumerate(body, 1)])
|
|
114
|
-
|
|
114
|
+
for seg in split_table_text(t):
|
|
115
|
+
chunks.append(Chunk(seg, " > ".join(heading), "table",
|
|
116
|
+
len(chunks)))
|
|
115
117
|
flush()
|
|
116
118
|
return chunks
|
|
117
119
|
|
|
@@ -157,7 +159,9 @@ def _xlsx_chunks(data: bytes) -> list[Chunk]:
|
|
|
157
159
|
header, *body = rows
|
|
158
160
|
text = "\n".join([f"Table — columns: {header}"]
|
|
159
161
|
+ [f"row {i}: {r}" for i, r in enumerate(body, 1)])
|
|
160
|
-
|
|
162
|
+
for seg in split_table_text(text):
|
|
163
|
+
chunks.append(Chunk(seg, f"Sheet: {ws.title}", "table",
|
|
164
|
+
len(chunks)))
|
|
161
165
|
wb.close()
|
|
162
166
|
return chunks
|
|
163
167
|
|
|
@@ -191,7 +195,8 @@ def _csv_chunks(data: bytes) -> list[Chunk]:
|
|
|
191
195
|
header, *body = rows
|
|
192
196
|
out = "\n".join([f"Table — columns: {' | '.join(header)}"]
|
|
193
197
|
+ [f"row {i}: {' | '.join(r)}" for i, r in enumerate(body, 1)])
|
|
194
|
-
return
|
|
198
|
+
return [Chunk(seg, "", "table", i)
|
|
199
|
+
for i, seg in enumerate(split_table_text(out))]
|
|
195
200
|
|
|
196
201
|
|
|
197
202
|
def extract_file_chunks(filename: str, data: bytes) -> tuple[list[Chunk], list[str]]:
|
|
@@ -195,7 +195,9 @@ class Index:
|
|
|
195
195
|
(model_id,))
|
|
196
196
|
|
|
197
197
|
def publish(self, seen_page_ids: set[str]) -> None:
|
|
198
|
-
"""Atomically make exactly the crawled page set the active snapshot.
|
|
198
|
+
"""Atomically make exactly the crawled page set the active snapshot.
|
|
199
|
+
Content belonging to pages no longer seen is DELETED — inactive rows
|
|
200
|
+
are unreachable forever anyway and would otherwise bloat every table."""
|
|
199
201
|
with self.lock, self.db:
|
|
200
202
|
self.db.execute("UPDATE pages SET active=0")
|
|
201
203
|
self.db.execute("UPDATE chunks SET active=0")
|
|
@@ -204,6 +206,20 @@ class Index:
|
|
|
204
206
|
ids = list(seen_page_ids)
|
|
205
207
|
self.db.execute(f"UPDATE pages SET active=1 WHERE page_id IN ({q})", ids)
|
|
206
208
|
self.db.execute(f"UPDATE chunks SET active=1 WHERE page_id IN ({q})", ids)
|
|
209
|
+
# gc: drop chunks (and their retrieval rows) of inactive pages,
|
|
210
|
+
# then the inactive page rows themselves
|
|
211
|
+
dead_chunks = [r[0] for r in self.db.execute(
|
|
212
|
+
"SELECT chunk_id FROM chunks WHERE page_id IN "
|
|
213
|
+
"(SELECT page_id FROM pages WHERE active=0)")]
|
|
214
|
+
if dead_chunks:
|
|
215
|
+
q = ",".join("?" * len(dead_chunks))
|
|
216
|
+
for t in ("chunks_fts", "vectors", "sparse_vectors"):
|
|
217
|
+
self.db.execute(
|
|
218
|
+
f"DELETE FROM {t} WHERE chunk_id IN ({q})",
|
|
219
|
+
dead_chunks)
|
|
220
|
+
self.db.execute(f"DELETE FROM chunks WHERE chunk_id IN ({q})",
|
|
221
|
+
dead_chunks)
|
|
222
|
+
self.db.execute("DELETE FROM pages WHERE active=0")
|
|
207
223
|
|
|
208
224
|
# ---- query ----
|
|
209
225
|
def active_pages(self) -> dict[str, dict]:
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/retrieval.py
RENAMED
|
@@ -39,7 +39,8 @@ MODEL_MARKER = f"{EMBED_MODEL_ID}+{SPARSE_MODEL_ID}"
|
|
|
39
39
|
TOP_K_RETRIEVE = 30
|
|
40
40
|
TOP_K_EVIDENCE = 8
|
|
41
41
|
RERANK_MAX = 25 # bounds cross-encoder latency on CPU
|
|
42
|
-
MIN_RERANK_SCORE = 0.
|
|
42
|
+
MIN_RERANK_SCORE = 0.02 # noise floor — real reranker zeros junk at ~0.00
|
|
43
|
+
LOW_CONFIDENCE_SCORE = 0.3 # below this the hit is surfaced but flagged
|
|
43
44
|
MAX_SEARCHABLE_CHUNKS = 100_000 # whole-index search is O(n) in RAM
|
|
44
45
|
EMBED_MAX_TOKENS = 512 # quantized model input cap (chunks stay under)
|
|
45
46
|
RERANK_MAX_TOKENS = 512
|
|
@@ -374,11 +375,14 @@ def retrieve(index: Index, query: str, embedder: LocalEmbedder,
|
|
|
374
375
|
evidence = []
|
|
375
376
|
for i in order:
|
|
376
377
|
if scores[i] < MIN_RERANK_SCORE:
|
|
377
|
-
# below the floor the reranker is only ranking "least
|
|
378
|
-
# surfacing it would ground the answer on
|
|
378
|
+
# below the noise floor the reranker is only ranking "least
|
|
379
|
+
# bad" — surfacing it would ground the answer on junk
|
|
379
380
|
continue
|
|
380
381
|
c = dict(scored[i])
|
|
381
382
|
c["rerank_score"] = float(scores[i])
|
|
383
|
+
# borderline matches are surfaced but flagged — the agent can
|
|
384
|
+
# caveat instead of answering on silently-weak evidence
|
|
385
|
+
c["low_confidence"] = bool(scores[i] < LOW_CONFIDENCE_SCORE)
|
|
382
386
|
evidence.append(c)
|
|
383
387
|
else:
|
|
384
388
|
evidence = [dict(c, rerank_score=None) for c in candidates[:TOP_K_EVIDENCE]]
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/server.py
RENAMED
|
@@ -40,7 +40,7 @@ mcp = FastMCP("confluence-knowledge")
|
|
|
40
40
|
|
|
41
41
|
# in-process caches (per server lifetime)
|
|
42
42
|
_scopes: dict[str, dict] = {}
|
|
43
|
-
_evidence_sets: dict[str,
|
|
43
|
+
_evidence_sets: dict[str, dict] = {} # set_id → {scope, ev}
|
|
44
44
|
_indexes: dict[str, Index] = {} # one connection per DB for the server lifetime
|
|
45
45
|
_clients: dict[str, ConfluenceClient] = {} # one HTTP pool per credential
|
|
46
46
|
_EMBEDDER = None
|
|
@@ -333,7 +333,10 @@ def search(scope_handle: str, question: str) -> dict:
|
|
|
333
333
|
"— run start_crawl once to rebuild it, then search again"}
|
|
334
334
|
ev = retrieve(idx, question, _embedder(), _reranker(), sparse=_sparse())
|
|
335
335
|
set_id = f"ev:{len(_evidence_sets)}:{s['key'][:8]}"
|
|
336
|
-
|
|
336
|
+
# bound the session cache — sets are never reused after the turn ends
|
|
337
|
+
while len(_evidence_sets) >= 32:
|
|
338
|
+
_evidence_sets.pop(next(iter(_evidence_sets)))
|
|
339
|
+
_evidence_sets[set_id] = {"scope": s["key"], "ev": ev}
|
|
337
340
|
return {
|
|
338
341
|
"evidence_set": set_id,
|
|
339
342
|
"coverage": _coverage(idx),
|
|
@@ -341,7 +344,9 @@ def search(scope_handle: str, question: str) -> dict:
|
|
|
341
344
|
"evidence": [
|
|
342
345
|
{"chunk_id": e["chunk_id"], "page_title": e["page_title"],
|
|
343
346
|
"page_url": e["page_url"], "heading": e["heading"],
|
|
344
|
-
"score": e.get("rerank_score"), "text": e["text"][:
|
|
347
|
+
"score": e.get("rerank_score"), "text": e["text"][:12000],
|
|
348
|
+
"low_confidence": e.get("low_confidence", False),
|
|
349
|
+
"truncated": len(e["text"]) > 12000}
|
|
345
350
|
for e in ev
|
|
346
351
|
],
|
|
347
352
|
}
|
|
@@ -367,12 +372,13 @@ def get_evidence(scope_handle: str, chunk_ids: list[str]) -> dict:
|
|
|
367
372
|
@mcp.tool()
|
|
368
373
|
def verify_citations(scope_handle: str, evidence_set: str,
|
|
369
374
|
mappings: list[dict]) -> dict:
|
|
370
|
-
_scope(scope_handle)
|
|
371
|
-
|
|
372
|
-
|
|
375
|
+
s = _scope(scope_handle) # binds or SetupError — never KeyError below
|
|
376
|
+
rec = _evidence_sets.get(evidence_set)
|
|
377
|
+
# full 24-char scope key — the key[:8] suffix in the id is cosmetic
|
|
378
|
+
if rec is None or rec["scope"] != s["key"]:
|
|
373
379
|
raise SecurityException(
|
|
374
380
|
"unknown or cross-scope evidence set — run search again and cite its evidence_set")
|
|
375
|
-
return check_citations(ev, mappings)
|
|
381
|
+
return check_citations(rec["ev"], mappings)
|
|
376
382
|
|
|
377
383
|
|
|
378
384
|
def main() -> None:
|
|
File without changes
|
|
File without changes
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/client.py
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/health.py
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/models.lock.json
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/registration.py
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/settings.py
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/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.2 → confluence_knowledge-0.5.3}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|