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.
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/PKG-INFO +1 -1
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/pyproject.toml +1 -1
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/crawl.py +7 -1
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/extract.py +25 -2
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/extract_files.py +9 -4
- confluence_knowledge-0.5.4/src/confluence_knowledge/feedback.py +91 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/index.py +17 -1
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/retrieval.py +7 -3
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/server.py +51 -8
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/PKG-INFO +1 -1
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/SOURCES.txt +2 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_agent_doc_consistency.py +18 -0
- confluence_knowledge-0.5.4/tests/test_feedback.py +106 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/README.md +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/setup.cfg +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/__init__.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/auth.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/cli.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/client.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/health.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/models.lock.json +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/registration.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/settings.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/update.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/dependency_links.txt +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/entry_points.txt +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/requires.txt +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge.egg-info/top_level.txt +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_client_get_only.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_crawl.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_extract.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_health_checks.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_index.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_prepare_models.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_registration.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_retrieval.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_secrets.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_server_flow.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_sparse_retrieval.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_stale_model.py +0 -0
- {confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/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.4}/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.4}/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]]:
|
|
@@ -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]:
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/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.4}/src/confluence_knowledge/server.py
RENAMED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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"][:
|
|
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
|
-
|
|
372
|
-
|
|
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:
|
|
@@ -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
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/tests/test_agent_doc_consistency.py
RENAMED
|
@@ -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"] == []
|
|
File without changes
|
|
File without changes
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/client.py
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/health.py
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/models.lock.json
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/registration.py
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/settings.py
RENAMED
|
File without changes
|
{confluence_knowledge-0.5.2 → confluence_knowledge-0.5.4}/src/confluence_knowledge/update.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|