confluence-knowledge 0.5.3__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.3 → confluence_knowledge-0.5.4}/PKG-INFO +1 -1
  2. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/pyproject.toml +1 -1
  3. confluence_knowledge-0.5.4/src/confluence_knowledge/feedback.py +91 -0
  4. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge/server.py +38 -1
  5. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/PKG-INFO +1 -1
  6. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/SOURCES.txt +2 -0
  7. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/tests/test_agent_doc_consistency.py +18 -0
  8. confluence_knowledge-0.5.4/tests/test_feedback.py +106 -0
  9. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/README.md +0 -0
  10. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/setup.cfg +0 -0
  11. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge/__init__.py +0 -0
  12. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge/auth.py +0 -0
  13. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge/cli.py +0 -0
  14. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge/client.py +0 -0
  15. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge/crawl.py +0 -0
  16. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge/extract.py +0 -0
  17. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge/extract_files.py +0 -0
  18. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge/health.py +0 -0
  19. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge/index.py +0 -0
  20. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge/models.lock.json +0 -0
  21. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge/registration.py +0 -0
  22. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge/retrieval.py +0 -0
  23. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge/settings.py +0 -0
  24. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge/update.py +0 -0
  25. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/dependency_links.txt +0 -0
  26. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/entry_points.txt +0 -0
  27. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/requires.txt +0 -0
  28. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/top_level.txt +0 -0
  29. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/tests/test_client_get_only.py +0 -0
  30. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/tests/test_crawl.py +0 -0
  31. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/tests/test_extract.py +0 -0
  32. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/tests/test_health_checks.py +0 -0
  33. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/tests/test_index.py +0 -0
  34. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/tests/test_prepare_models.py +0 -0
  35. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/tests/test_registration.py +0 -0
  36. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/tests/test_retrieval.py +0 -0
  37. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/tests/test_secrets.py +0 -0
  38. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/tests/test_server_flow.py +0 -0
  39. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/tests/test_sparse_retrieval.py +0 -0
  40. {confluence_knowledge-0.5.3 → confluence_knowledge-0.5.4}/tests/test_stale_model.py +0 -0
  41. {confluence_knowledge-0.5.3 → 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.3
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.3"
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"
@@ -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}
@@ -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,
@@ -43,6 +44,7 @@ _scopes: dict[str, dict] = {}
43
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,7 +341,11 @@ 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
350
  # bound the session cache — sets are never reused after the turn ends
337
351
  while len(_evidence_sets) >= 32:
@@ -341,6 +355,7 @@ def search(scope_handle: str, question: str) -> dict:
341
355
  "evidence_set": set_id,
342
356
  "coverage": _coverage(idx),
343
357
  "as_of": st["as_of"],
358
+ "user_corrections": corrections,
344
359
  "evidence": [
345
360
  {"chunk_id": e["chunk_id"], "page_title": e["page_title"],
346
361
  "page_url": e["page_url"], "heading": e["heading"],
@@ -369,6 +384,28 @@ def get_evidence(scope_handle: str, chunk_ids: list[str]) -> dict:
369
384
  return {"chunks": out}
370
385
 
371
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
+
372
409
  @mcp.tool()
373
410
  def verify_citations(scope_handle: str, evidence_set: str,
374
411
  mappings: list[dict]) -> dict:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confluence-knowledge
3
- Version: 0.5.3
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"] == []