confluence-knowledge 0.5.2__tar.gz → 0.5.4__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 (41) hide show
  1. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/PKG-INFO +1 -1
  2. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/pyproject.toml +1 -1
  3. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/crawl.py +7 -1
  4. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/extract.py +25 -2
  5. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/extract_files.py +9 -4
  6. confluence_knowledge-0.5.4/src/confluence_knowledge/feedback.py +91 -0
  7. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/index.py +17 -1
  8. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/retrieval.py +7 -3
  9. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/server.py +51 -8
  10. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/PKG-INFO +1 -1
  11. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/SOURCES.txt +2 -0
  12. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_agent_doc_consistency.py +18 -0
  13. confluence_knowledge-0.5.4/tests/test_feedback.py +106 -0
  14. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/README.md +0 -0
  15. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/setup.cfg +0 -0
  16. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/__init__.py +0 -0
  17. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/auth.py +0 -0
  18. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/cli.py +0 -0
  19. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/client.py +0 -0
  20. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/health.py +0 -0
  21. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/models.lock.json +0 -0
  22. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/registration.py +0 -0
  23. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/settings.py +0 -0
  24. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/update.py +0 -0
  25. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/dependency_links.txt +0 -0
  26. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/entry_points.txt +0 -0
  27. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/requires.txt +0 -0
  28. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/top_level.txt +0 -0
  29. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_client_get_only.py +0 -0
  30. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_crawl.py +0 -0
  31. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_extract.py +0 -0
  32. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_health_checks.py +0 -0
  33. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_index.py +0 -0
  34. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_prepare_models.py +0 -0
  35. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_registration.py +0 -0
  36. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_retrieval.py +0 -0
  37. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_secrets.py +0 -0
  38. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_server_flow.py +0 -0
  39. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_sparse_retrieval.py +0 -0
  40. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_stale_model.py +0 -0
  41. {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/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.4
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.4"
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]]:
@@ -0,0 +1,91 @@
1
+ """Per-scope human-feedback store — colleague corrections keyed by query
2
+ embedding. Local-first: one JSON file per scope under the index dir;
3
+ `clean` removes them along with the index (documented semantics).
4
+
5
+ Design rules (from issue #107): corrections are disclosed ALONGSIDE
6
+ documentation — "the docs say X, but a colleague correction says Y" —
7
+ never silently override, never silently dropped.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import tempfile
13
+ import time
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+
18
+ # measured on real bge-m3: identical question 1.00, close paraphrase ~0.77,
19
+ # same-topic different-intent ~0.68, unrelated ~0.39 → 0.75 catches natural
20
+ # rephrases without leaking across topics
21
+ DEFAULT_MIN_SIM = 0.75
22
+
23
+
24
+ class FeedbackStore:
25
+ """JSON list under the index dir; same integrity discipline as Index
26
+ (tolerant load, atomic writes)."""
27
+
28
+ def __init__(self, index_root: Path, scope_key: str, cli: str):
29
+ self.path = index_root / f"{scope_key}.feedback.json"
30
+ env_key = cli.upper().replace("-", "_") + "_FEEDBACK_MIN_SIM"
31
+ self.min_sim = float(os.environ.get(
32
+ env_key, os.environ.get("KNOWLEDGE_FEEDBACK_MIN_SIM",
33
+ DEFAULT_MIN_SIM)))
34
+ self._entries = self._load()
35
+
36
+ def _load(self) -> list[dict]:
37
+ if not self.path.exists():
38
+ return []
39
+ try:
40
+ data = json.loads(self.path.read_text())
41
+ return data if isinstance(data, list) else []
42
+ except (json.JSONDecodeError, OSError):
43
+ return [] # corrupt file → start fresh, don't crash the server
44
+
45
+ def _save(self) -> None:
46
+ self.path.parent.mkdir(parents=True, exist_ok=True)
47
+ fd, tmp = tempfile.mkstemp(dir=self.path.parent, suffix=".tmp")
48
+ with os.fdopen(fd, "w") as f:
49
+ json.dump(self._entries, f)
50
+ os.replace(tmp, self.path) # atomic — a crash mid-write can't corrupt
51
+
52
+ def add(self, question: str, qvec: np.ndarray, correction: str,
53
+ note: str = "") -> dict:
54
+ entry = {"id": f"fb{len(self._entries):04d}",
55
+ "question": question,
56
+ "qvec": qvec.astype(np.float32).tolist(),
57
+ "correction": correction, "note": note,
58
+ "ts": int(time.time())}
59
+ self._entries.append(entry)
60
+ self._save()
61
+ return entry
62
+
63
+ def match(self, qvec: np.ndarray, k: int = 3) -> list[dict]:
64
+ """Cosine-similar corrections ≥ min_sim (default 0.75 — catches
65
+ paraphrases without leaking across topics), best first."""
66
+ if not self._entries:
67
+ return []
68
+ mat = np.array([e["qvec"] for e in self._entries], dtype=np.float32)
69
+ q = qvec / (np.linalg.norm(qvec) + 1e-9)
70
+ m = mat / (np.linalg.norm(mat, axis=1, keepdims=True) + 1e-9)
71
+ sims = m @ q
72
+ out = []
73
+ for i in np.argsort(-sims):
74
+ if sims[i] < self.min_sim or len(out) >= k:
75
+ break
76
+ e = dict(self._entries[int(i)])
77
+ e.pop("qvec", None)
78
+ e["similarity"] = float(sims[i])
79
+ e["age_days"] = int((time.time() - e["ts"]) / 86400)
80
+ out.append(e)
81
+ return out
82
+
83
+ def report(self) -> dict:
84
+ """Aggregate view for 'which pages keep getting corrected' — the
85
+ agent summarizes these as outdated/confusing doc candidates."""
86
+ out = []
87
+ for e in self._entries:
88
+ out.append({"question": e["question"], "correction": e["correction"],
89
+ "note": e["note"], "age_days":
90
+ int((time.time() - e["ts"]) / 86400)})
91
+ return {"count": len(out), "corrections": out}
@@ -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]]
@@ -16,6 +16,7 @@ from mcp.server.fastmcp import FastMCP
16
16
  from .auth import get_credential
17
17
  from .client import AccessError, ConfluenceClient, SecurityException, make_client
18
18
  from .crawl import Crawler
19
+ from .feedback import FeedbackStore
19
20
  from .index import Index
20
21
  from .retrieval import (
21
22
  MODEL_MARKER as EMBED_MODEL_ID,
@@ -40,9 +41,10 @@ mcp = FastMCP("confluence-knowledge")
40
41
 
41
42
  # in-process caches (per server lifetime)
42
43
  _scopes: dict[str, dict] = {}
43
- _evidence_sets: dict[str, list[dict]] = {}
44
+ _evidence_sets: dict[str, dict] = {} # set_id → {scope, ev}
44
45
  _indexes: dict[str, Index] = {} # one connection per DB for the server lifetime
45
46
  _clients: dict[str, ConfluenceClient] = {} # one HTTP pool per credential
47
+ _feedback: dict[str, FeedbackStore] = {} # per-scope correction stores
46
48
  _EMBEDDER = None
47
49
  _RERANKER = None
48
50
  _SPARSE = None
@@ -85,6 +87,14 @@ def _index_for(scope_key: str) -> Index:
85
87
  return idx
86
88
 
87
89
 
90
+ def _feedback_for(scope_key: str) -> FeedbackStore:
91
+ st = _feedback.get(scope_key)
92
+ if st is None:
93
+ st = _feedback[scope_key] = FeedbackStore(
94
+ workspace_index_root(_workdir()), scope_key, "confluence-knowledge")
95
+ return st
96
+
97
+
88
98
  def _embedder():
89
99
  global _EMBEDDER
90
100
  with _model_lock:
@@ -331,17 +341,27 @@ def search(scope_handle: str, question: str) -> dict:
331
341
  return {"evidence": [], "coverage": "stale_model",
332
342
  "note": "this index was built with an older embedding model "
333
343
  "— run start_crawl once to rebuild it, then search again"}
334
- ev = retrieve(idx, question, _embedder(), _reranker(), sparse=_sparse())
344
+ emb = _embedder()
345
+ ev = retrieve(idx, question, emb, _reranker(), sparse=_sparse())
346
+ # colleague corrections matching this question ride along — the agent
347
+ # must disclose them, so they're part of the response, not a separate call
348
+ corrections = _feedback_for(s["key"]).match(emb.embed([question])[0])
335
349
  set_id = f"ev:{len(_evidence_sets)}:{s['key'][:8]}"
336
- _evidence_sets[set_id] = ev
350
+ # bound the session cache — sets are never reused after the turn ends
351
+ while len(_evidence_sets) >= 32:
352
+ _evidence_sets.pop(next(iter(_evidence_sets)))
353
+ _evidence_sets[set_id] = {"scope": s["key"], "ev": ev}
337
354
  return {
338
355
  "evidence_set": set_id,
339
356
  "coverage": _coverage(idx),
340
357
  "as_of": st["as_of"],
358
+ "user_corrections": corrections,
341
359
  "evidence": [
342
360
  {"chunk_id": e["chunk_id"], "page_title": e["page_title"],
343
361
  "page_url": e["page_url"], "heading": e["heading"],
344
- "score": e.get("rerank_score"), "text": e["text"][:2000]}
362
+ "score": e.get("rerank_score"), "text": e["text"][:12000],
363
+ "low_confidence": e.get("low_confidence", False),
364
+ "truncated": len(e["text"]) > 12000}
345
365
  for e in ev
346
366
  ],
347
367
  }
@@ -364,15 +384,38 @@ def get_evidence(scope_handle: str, chunk_ids: list[str]) -> dict:
364
384
  return {"chunks": out}
365
385
 
366
386
 
387
+ @mcp.tool()
388
+ def submit_feedback(scope_handle: str, question: str, correction: str,
389
+ note: str = "") -> dict:
390
+ """Store a colleague correction — disclosed alongside the docs on
391
+ similar future questions (never overrides them)."""
392
+ s = _scope(scope_handle)
393
+ if not _models_loaded(0):
394
+ return {"stored": False, "note": LOADING_NOTE}
395
+ qvec = _embedder().embed([question])[0]
396
+ entry = _feedback_for(s["key"]).add(question, qvec, correction, note)
397
+ return {"stored": entry["id"],
398
+ "note": "will be disclosed on similar questions"}
399
+
400
+
401
+ @mcp.tool()
402
+ def feedback_report(scope_handle: str) -> dict:
403
+ """Aggregated corrections — which topics keep getting corrected points
404
+ at outdated/confusing documentation."""
405
+ s = _scope(scope_handle)
406
+ return _feedback_for(s["key"]).report()
407
+
408
+
367
409
  @mcp.tool()
368
410
  def verify_citations(scope_handle: str, evidence_set: str,
369
411
  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]):
412
+ s = _scope(scope_handle) # binds or SetupError — never KeyError below
413
+ rec = _evidence_sets.get(evidence_set)
414
+ # full 24-char scope key — the key[:8] suffix in the id is cosmetic
415
+ if rec is None or rec["scope"] != s["key"]:
373
416
  raise SecurityException(
374
417
  "unknown or cross-scope evidence set — run search again and cite its evidence_set")
375
- return check_citations(ev, mappings)
418
+ return check_citations(rec["ev"], mappings)
376
419
 
377
420
 
378
421
  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.4
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
@@ -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/feedback.py
10
11
  src/confluence_knowledge/health.py
11
12
  src/confluence_knowledge/index.py
12
13
  src/confluence_knowledge/models.lock.json
@@ -25,6 +26,7 @@ tests/test_agent_doc_consistency.py
25
26
  tests/test_client_get_only.py
26
27
  tests/test_crawl.py
27
28
  tests/test_extract.py
29
+ tests/test_feedback.py
28
30
  tests/test_health_checks.py
29
31
  tests/test_index.py
30
32
  tests/test_prepare_models.py
@@ -32,3 +32,21 @@ def test_agent_references_repo_files_that_exist():
32
32
  doc = AGENT.read_text()
33
33
  for m in re.findall(r"prompts/([\w.-]+)", doc):
34
34
  assert (REPO / "prompts" / m).exists(), f"agent references missing prompts/{m}"
35
+
36
+
37
+ def test_agent_frontmatter_tools_exist():
38
+ """Every tool named in the agent's frontmatter must be a real
39
+ @mcp.tool() in the server source."""
40
+ if not AGENT.exists():
41
+ import pytest
42
+ pytest.skip("agent file not in this checkout layout")
43
+ doc = AGENT.read_text()
44
+ tools_line = re.search(r'tools:\s*\[([^\]]+)\]', doc).group(1)
45
+ declared = {t.strip().strip('"').split("/")[1]
46
+ for t in tools_line.split(",")
47
+ if "confluence-knowledge/" in t}
48
+ srv = (Path(__file__).parents[1] / "src" /
49
+ "confluence_knowledge" / "server.py").read_text()
50
+ real = set(re.findall(r"@mcp\.tool\(\)\s*\ndef\s+(\w+)", srv))
51
+ missing = declared - real
52
+ assert not missing, f"agent frontmatter names tools not in server: {missing}"
@@ -0,0 +1,106 @@
1
+ """FeedbackStore: per-scope corrections, threshold matching, integrity."""
2
+
3
+ import numpy as np
4
+
5
+ from confluence_knowledge.feedback import FeedbackStore
6
+
7
+
8
+ def _v(seed: int) -> np.ndarray:
9
+ rng = np.random.default_rng(seed)
10
+ v = rng.random(16).astype(np.float32)
11
+ return v / np.linalg.norm(v)
12
+
13
+
14
+ def test_add_and_match(tmp_path):
15
+ st = FeedbackStore(tmp_path, "scope1", "ck-test")
16
+ st.add("password policy?", _v(1), "it is actually 16 chars")
17
+ hits = st.match(_v(1))
18
+ assert len(hits) == 1 and hits[0]["correction"].startswith("it is")
19
+ assert "qvec" not in hits[0] and hits[0]["similarity"] >= 0.85
20
+
21
+
22
+ def test_threshold_rejects_unrelated(tmp_path):
23
+ st = FeedbackStore(tmp_path, "scope1", "ck-test")
24
+ st.add("password policy?", _v(1), "correction")
25
+ # orthogonal vector — sim 0.0 (16-dim random vectors can collide at >0.75)
26
+ q = np.zeros(16, dtype=np.float32)
27
+ q[7] = 1.0
28
+ stored = np.array([np.array(e["qvec"]) for e in st._entries])
29
+ assert float(q @ stored[0]) < 0.75
30
+ assert st.match(q) == []
31
+
32
+
33
+ def test_scopes_isolated(tmp_path):
34
+ a = FeedbackStore(tmp_path, "scopeA", "ck-test")
35
+ b = FeedbackStore(tmp_path, "scopeB", "ck-test")
36
+ a.add("q", _v(1), "for A only")
37
+ assert b.match(_v(1)) == []
38
+ assert a.path.name != b.path.name
39
+
40
+
41
+ def test_corrupt_file_tolerated(tmp_path):
42
+ (tmp_path / "scope1.feedback.json").write_text("{not json!!")
43
+ st = FeedbackStore(tmp_path, "scope1", "ck-test")
44
+ assert st.report()["count"] == 0 # loads empty, doesn't crash
45
+
46
+
47
+ def test_persists_across_instances(tmp_path):
48
+ FeedbackStore(tmp_path, "s", "ck-test").add("q", _v(2), "corr")
49
+ st = FeedbackStore(tmp_path, "s", "ck-test")
50
+ assert st.report()["count"] == 1
51
+
52
+
53
+ def test_report_shape(tmp_path):
54
+ st = FeedbackStore(tmp_path, "s", "ck-test")
55
+ st.add("q1", _v(1), "c1", note="why")
56
+ r = st.report()
57
+ assert r["count"] == 1
58
+ assert r["corrections"][0]["note"] == "why"
59
+ assert "qvec" not in r["corrections"][0]
60
+
61
+
62
+ def test_search_injects_corrections(tmp_path, monkeypatch):
63
+ """submit_feedback → a similar question's search carries it through."""
64
+ from confluence_knowledge import server
65
+ from confluence_knowledge.extract import Chunk
66
+ from confluence_knowledge.index import Index
67
+ server._scopes.clear()
68
+ server._feedback.clear()
69
+ server._evidence_sets.clear()
70
+ monkeypatch.setenv("CONF_KNOWLEDGE_WORKDIR", str(tmp_path))
71
+ monkeypatch.setenv("CONF_KNOWLEDGE_HOME", str(tmp_path / "home"))
72
+ (tmp_path / "home").mkdir()
73
+
74
+ s = {"key": "k123", "profile": None, "root": "r"}
75
+ server._scopes["scope:k:r"] = s
76
+
77
+ # index with one page so search doesn't early-return "empty"
78
+ idx = Index(tmp_path / ".conf_knowledge_index" / "k123.db")
79
+ c = Chunk(text="old answer text", heading_path="", kind="text",
80
+ ordinal=0, chunk_id="p1:0:0")
81
+ idx.upsert_page({"page_id": "p1", "parent_id": None, "title": "P",
82
+ "url": "u", "level": 0, "version": "", "warnings": ""},
83
+ [c], vecs=None, sparse=None)
84
+ idx.publish({"p1"})
85
+
86
+ import rag.conftest as rc
87
+ emb = rc.HashEmbedder()
88
+ monkeypatch.setattr(server, "_embedder", lambda: emb)
89
+ monkeypatch.setattr(server, "_reranker", lambda: None)
90
+ monkeypatch.setattr(server, "_sparse", lambda: None)
91
+ monkeypatch.setattr(server, "_models_loaded", lambda _w: True)
92
+ monkeypatch.setattr(server, "_index_for", lambda k: idx)
93
+ monkeypatch.setattr(server, "_feedback_for",
94
+ lambda k: server._feedback.setdefault(
95
+ k, server.FeedbackStore(
96
+ tmp_path / ".conf_knowledge_index",
97
+ k, "confluence-knowledge")))
98
+
99
+ server.submit_feedback("scope:k:r", "what is the password policy",
100
+ "14 is wrong, it's 16")
101
+ res = server.search("scope:k:r", "what is the password policy")
102
+ assert res["user_corrections"] and \
103
+ res["user_corrections"][0]["correction"] == "14 is wrong, it's 16"
104
+ # unrelated question → no injection
105
+ res2 = server.search("scope:k:r", "zebra quantum xyzzy nonsense")
106
+ assert res2["user_corrections"] == []