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.
Files changed (39) hide show
  1. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/PKG-INFO +1 -1
  2. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/pyproject.toml +1 -1
  3. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/crawl.py +7 -1
  4. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/extract.py +25 -2
  5. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/extract_files.py +9 -4
  6. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/index.py +17 -1
  7. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/retrieval.py +7 -3
  8. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/server.py +13 -7
  9. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge.egg-info/PKG-INFO +1 -1
  10. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/README.md +0 -0
  11. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/setup.cfg +0 -0
  12. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/__init__.py +0 -0
  13. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/auth.py +0 -0
  14. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/cli.py +0 -0
  15. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/client.py +0 -0
  16. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/health.py +0 -0
  17. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/models.lock.json +0 -0
  18. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/registration.py +0 -0
  19. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/settings.py +0 -0
  20. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge/update.py +0 -0
  21. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge.egg-info/SOURCES.txt +0 -0
  22. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge.egg-info/dependency_links.txt +0 -0
  23. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge.egg-info/entry_points.txt +0 -0
  24. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge.egg-info/requires.txt +0 -0
  25. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/src/confluence_knowledge.egg-info/top_level.txt +0 -0
  26. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_agent_doc_consistency.py +0 -0
  27. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_client_get_only.py +0 -0
  28. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_crawl.py +0 -0
  29. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_extract.py +0 -0
  30. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_health_checks.py +0 -0
  31. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_index.py +0 -0
  32. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_prepare_models.py +0 -0
  33. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_registration.py +0 -0
  34. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_retrieval.py +0 -0
  35. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_secrets.py +0 -0
  36. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_server_flow.py +0 -0
  37. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_sparse_retrieval.py +0 -0
  38. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/tests/test_stale_model.py +0 -0
  39. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.3}/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.2
3
+ Version: 0.5.3
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.2"
7
+ version = "0.5.3"
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"
@@ -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"
@@ -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
- if t:
114
- chunks.append(Chunk(t, " > ".join(heading_path), "table", len(chunks)))
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()
@@ -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
- chunks.extend(_text_chunks(t, " > ".join(heading), "table"))
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
- chunks.extend(_text_chunks(text, f"Sheet: {ws.title}", "table"))
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 _text_chunks(out, "", "table")
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]:
@@ -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.3 # sigmoid floor — below this a hit is "least bad"
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 bad" —
378
- # surfacing it would ground the answer on noise
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]]
@@ -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, list[dict]] = {}
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
- _evidence_sets[set_id] = ev
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"][:2000]}
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
- ev = _evidence_sets.get(evidence_set)
372
- if ev is None or not evidence_set.endswith(_scopes[scope_handle]["key"][:8]):
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confluence-knowledge
3
- Version: 0.5.2
3
+ Version: 0.5.3
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