confluence-knowledge 0.3.1__py3-none-any.whl
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/__init__.py +3 -0
- confluence_knowledge/auth.py +83 -0
- confluence_knowledge/cli.py +180 -0
- confluence_knowledge/client.py +152 -0
- confluence_knowledge/crawl.py +236 -0
- confluence_knowledge/extract.py +118 -0
- confluence_knowledge/index.py +193 -0
- confluence_knowledge/models.lock.json +15 -0
- confluence_knowledge/registration.py +134 -0
- confluence_knowledge/retrieval.py +207 -0
- confluence_knowledge/server.py +341 -0
- confluence_knowledge/settings.py +156 -0
- confluence_knowledge-0.3.1.dist-info/METADATA +77 -0
- confluence_knowledge-0.3.1.dist-info/RECORD +17 -0
- confluence_knowledge-0.3.1.dist-info/WHEEL +5 -0
- confluence_knowledge-0.3.1.dist-info/entry_points.txt +2 -0
- confluence_knowledge-0.3.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Storage-format XHTML → semantic chunks.
|
|
2
|
+
|
|
3
|
+
Local parse only (no external entity/network resolution), heading-aware block
|
|
4
|
+
splitting with bounded overlap. Page content is data, never instructions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from bs4 import BeautifulSoup
|
|
13
|
+
|
|
14
|
+
MAX_WORDS = 280
|
|
15
|
+
OVERLAP_WORDS = 40
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Chunk:
|
|
20
|
+
text: str
|
|
21
|
+
heading_path: str # "Parent section > Sub"
|
|
22
|
+
kind: str # text | table
|
|
23
|
+
ordinal: int
|
|
24
|
+
chunk_id: str = "" # assigned at index time: page_id:hash:ordinal
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _table_to_text(tbl) -> str:
|
|
28
|
+
rows = []
|
|
29
|
+
for tr in tbl.find_all("tr"):
|
|
30
|
+
cells = [c.get_text(" ", strip=True) for c in tr.find_all(["th", "td"])]
|
|
31
|
+
if cells:
|
|
32
|
+
rows.append(" | ".join(cells))
|
|
33
|
+
if not rows:
|
|
34
|
+
return ""
|
|
35
|
+
header, *body = rows
|
|
36
|
+
out = [f"Table — columns: {header}"]
|
|
37
|
+
out += [f"row {i}: {r}" for i, r in enumerate(body, 1)]
|
|
38
|
+
return "\n".join(out)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def extract_chunks(storage_html: str) -> tuple[list[Chunk], list[str]]:
|
|
42
|
+
"""Return (chunks, extraction warnings)."""
|
|
43
|
+
soup = BeautifulSoup(storage_html, "html.parser") # stdlib parser; no ext entities
|
|
44
|
+
warnings: list[str] = []
|
|
45
|
+
|
|
46
|
+
# macros that render external/dynamic content — skip entirely
|
|
47
|
+
SKIP_MACROS = {"jira", "jirachart", "rss", "include", "widget", "gadget",
|
|
48
|
+
"livesearch", "recently-updated", "view-file", "gallery",
|
|
49
|
+
"content-by-label", "children", "pagetree", "excerpt-include",
|
|
50
|
+
"multiexcerpt-include"}
|
|
51
|
+
skipped_macros: set[str] = set()
|
|
52
|
+
for tag in soup.find_all(["script", "style", "ac:structured-macro"]):
|
|
53
|
+
if tag.name.startswith("ac:"):
|
|
54
|
+
name = tag.get("ac:name", "")
|
|
55
|
+
if name in SKIP_MACROS:
|
|
56
|
+
skipped_macros.add(name)
|
|
57
|
+
tag.decompose()
|
|
58
|
+
continue
|
|
59
|
+
# container macros (code, expand, info, panel...) — keep the body:
|
|
60
|
+
# rich-text bodies are unwrapped so their markup chunks normally;
|
|
61
|
+
# plain-text bodies (code) become <pre> so the walker keeps order
|
|
62
|
+
rich = tag.find("ac:rich-text-body")
|
|
63
|
+
plain = tag.find("ac:plain-text-body")
|
|
64
|
+
if plain is not None:
|
|
65
|
+
pre = soup.new_tag("pre")
|
|
66
|
+
pre.string = plain.get_text(strip=True)
|
|
67
|
+
tag.replace_with(pre)
|
|
68
|
+
elif rich is not None:
|
|
69
|
+
rich.unwrap()
|
|
70
|
+
tag.unwrap()
|
|
71
|
+
else:
|
|
72
|
+
tag.decompose()
|
|
73
|
+
continue
|
|
74
|
+
tag.decompose()
|
|
75
|
+
if skipped_macros:
|
|
76
|
+
warnings.append(
|
|
77
|
+
"skipped dynamic macros (content may be incomplete): "
|
|
78
|
+
+ ", ".join(sorted(skipped_macros)))
|
|
79
|
+
|
|
80
|
+
chunks: list[Chunk] = []
|
|
81
|
+
buf: list[str] = []
|
|
82
|
+
heading_path: list[str] = []
|
|
83
|
+
|
|
84
|
+
def flush(kind: str = "text"):
|
|
85
|
+
text = "\n".join(buf).strip()
|
|
86
|
+
buf.clear()
|
|
87
|
+
if not text:
|
|
88
|
+
return
|
|
89
|
+
words = text.split()
|
|
90
|
+
if len(words) <= MAX_WORDS:
|
|
91
|
+
chunks.append(Chunk(text, " > ".join(heading_path), kind, len(chunks)))
|
|
92
|
+
return
|
|
93
|
+
# split on word boundaries with overlap
|
|
94
|
+
step = MAX_WORDS - OVERLAP_WORDS
|
|
95
|
+
for i in range(0, len(words), step):
|
|
96
|
+
seg = " ".join(words[i:i + MAX_WORDS])
|
|
97
|
+
if seg.strip():
|
|
98
|
+
chunks.append(Chunk(seg, " > ".join(heading_path), kind, len(chunks)))
|
|
99
|
+
|
|
100
|
+
for el in soup.find_all(re.compile(r"^h[1-6]$|^p$|^ul$|^ol$|^table$|^pre$|^blockquote$")):
|
|
101
|
+
if el.name and el.name.startswith("h"):
|
|
102
|
+
flush()
|
|
103
|
+
level = int(el.name[1])
|
|
104
|
+
title = el.get_text(" ", strip=True)
|
|
105
|
+
heading_path = heading_path[: level - 1]
|
|
106
|
+
while len(heading_path) < level - 1:
|
|
107
|
+
heading_path.append("")
|
|
108
|
+
heading_path.append(title)
|
|
109
|
+
buf.append(f"## {title}")
|
|
110
|
+
elif el.name == "table":
|
|
111
|
+
flush()
|
|
112
|
+
t = _table_to_text(el)
|
|
113
|
+
if t:
|
|
114
|
+
chunks.append(Chunk(t, " > ".join(heading_path), "table", len(chunks)))
|
|
115
|
+
else:
|
|
116
|
+
buf.append(el.get_text(" ", strip=True))
|
|
117
|
+
flush()
|
|
118
|
+
return chunks, warnings
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Local persistent index: SQLite metadata + FTS5 keyword + vector blobs.
|
|
2
|
+
|
|
3
|
+
One DB per scope (site+account+root). Single-writer transactions; a completed
|
|
4
|
+
crawl is published atomically so a failed refresh never clobbers a good index.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
SCHEMA_VERSION = 1
|
|
18
|
+
|
|
19
|
+
SCHEMA = """
|
|
20
|
+
PRAGMA journal_mode=WAL;
|
|
21
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
22
|
+
CREATE TABLE IF NOT EXISTS pages (
|
|
23
|
+
page_id TEXT PRIMARY KEY, parent_id TEXT, title TEXT, url TEXT,
|
|
24
|
+
level INTEGER, version TEXT, fetched_at REAL, content_hash TEXT,
|
|
25
|
+
warnings TEXT, active INTEGER DEFAULT 0);
|
|
26
|
+
CREATE TABLE IF NOT EXISTS chunks (
|
|
27
|
+
chunk_id TEXT PRIMARY KEY, page_id TEXT, heading TEXT, kind TEXT,
|
|
28
|
+
ordinal INTEGER, text TEXT, active INTEGER DEFAULT 0);
|
|
29
|
+
CREATE TABLE IF NOT EXISTS vectors (chunk_id TEXT PRIMARY KEY, vec BLOB);
|
|
30
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
|
|
31
|
+
chunk_id UNINDEXED, text);
|
|
32
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
33
|
+
job_id TEXT PRIMARY KEY, root_page TEXT, state TEXT, created_at REAL,
|
|
34
|
+
queue TEXT, errors TEXT);
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Index:
|
|
39
|
+
def __init__(self, path: Path):
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
# timeout: retry transient locks (AV scan, second process, WAL
|
|
42
|
+
# checkpoint) instead of failing instantly; check_same_thread: the
|
|
43
|
+
# crawl worker and tool-call threads share this one connection —
|
|
44
|
+
# `self.lock` serializes every access so writes never interleave
|
|
45
|
+
self.db = sqlite3.connect(path, timeout=30, check_same_thread=False)
|
|
46
|
+
self.lock = threading.RLock()
|
|
47
|
+
with self.lock:
|
|
48
|
+
self.db.executescript(SCHEMA)
|
|
49
|
+
self.db.execute(
|
|
50
|
+
"INSERT OR IGNORE INTO meta VALUES ('schema_version', ?)",
|
|
51
|
+
(str(SCHEMA_VERSION),))
|
|
52
|
+
# an uncommitted INSERT holds the write lock for the connection's
|
|
53
|
+
# lifetime and deadlocks every other connection (#68)
|
|
54
|
+
self.db.commit()
|
|
55
|
+
|
|
56
|
+
# ---- jobs ----
|
|
57
|
+
def create_job(self, job_id: str, root_page: str) -> None:
|
|
58
|
+
with self.lock:
|
|
59
|
+
self.db.execute(
|
|
60
|
+
"INSERT OR REPLACE INTO jobs VALUES (?,?,?,?,?,?)",
|
|
61
|
+
(job_id, root_page, "queued", time.time(),
|
|
62
|
+
json.dumps([root_page]), "[]"))
|
|
63
|
+
self.db.commit()
|
|
64
|
+
|
|
65
|
+
def job(self, job_id: str) -> dict | None:
|
|
66
|
+
with self.lock:
|
|
67
|
+
row = self.db.execute(
|
|
68
|
+
"SELECT root_page,state,queue,errors FROM jobs WHERE job_id=?",
|
|
69
|
+
(job_id,)).fetchone()
|
|
70
|
+
if not row:
|
|
71
|
+
return None
|
|
72
|
+
return {"root_page": row[0], "state": row[1],
|
|
73
|
+
"queue": json.loads(row[2]), "errors": json.loads(row[3])}
|
|
74
|
+
|
|
75
|
+
def abandon_running_jobs(self) -> None:
|
|
76
|
+
"""A new crawl supersedes any unfinished one (e.g. an earlier failed
|
|
77
|
+
attempt) so it can't leave coverage reported as partial forever."""
|
|
78
|
+
with self.lock:
|
|
79
|
+
self.db.execute("UPDATE jobs SET state='abandoned' WHERE state='running'")
|
|
80
|
+
self.db.commit()
|
|
81
|
+
|
|
82
|
+
def latest_job(self) -> dict | None:
|
|
83
|
+
with self.lock:
|
|
84
|
+
row = self.db.execute(
|
|
85
|
+
"SELECT job_id,state,errors FROM jobs ORDER BY created_at DESC LIMIT 1").fetchone()
|
|
86
|
+
return row and {"job_id": row[0], "state": row[1], "errors": json.loads(row[2])}
|
|
87
|
+
|
|
88
|
+
def save_job(self, job_id: str, state: str, queue: list, errors: list) -> None:
|
|
89
|
+
with self.lock:
|
|
90
|
+
self.db.execute(
|
|
91
|
+
"UPDATE jobs SET state=?, queue=?, errors=? WHERE job_id=?",
|
|
92
|
+
(state, json.dumps(queue), json.dumps(errors), job_id))
|
|
93
|
+
self.db.commit()
|
|
94
|
+
|
|
95
|
+
def meta_get(self, key: str) -> str | None:
|
|
96
|
+
with self.lock:
|
|
97
|
+
row = self.db.execute(
|
|
98
|
+
"SELECT value FROM meta WHERE key=?", (key,)).fetchone()
|
|
99
|
+
return row[0] if row else None
|
|
100
|
+
|
|
101
|
+
def job_progress(self, job_id: str) -> dict:
|
|
102
|
+
"""Snapshot the agent can poll while a background worker crawls."""
|
|
103
|
+
job = self.job(job_id)
|
|
104
|
+
if job is None:
|
|
105
|
+
raise KeyError(job_id)
|
|
106
|
+
seen = self.meta_get(f"seen:{job_id}")
|
|
107
|
+
return {"job_id": job_id, "state": job["state"],
|
|
108
|
+
"remaining": len(job["queue"]),
|
|
109
|
+
"pages_seen": len(json.loads(seen)) if seen else 0,
|
|
110
|
+
"errors": job["errors"]}
|
|
111
|
+
|
|
112
|
+
# ---- content ----
|
|
113
|
+
def upsert_page(self, page: dict, chunks: list, vecs: np.ndarray | None) -> None:
|
|
114
|
+
with self.lock, self.db:
|
|
115
|
+
self.db.execute(
|
|
116
|
+
"INSERT OR REPLACE INTO pages VALUES (?,?,?,?,?,?,?,?,?,0)",
|
|
117
|
+
(page["page_id"], page.get("parent_id"), page["title"],
|
|
118
|
+
page["url"], page.get("level", 0), page.get("version"),
|
|
119
|
+
time.time(), page.get("content_hash", ""),
|
|
120
|
+
json.dumps(page.get("warnings", []))))
|
|
121
|
+
old = [r[0] for r in self.db.execute(
|
|
122
|
+
"SELECT chunk_id FROM chunks WHERE page_id=?", (page["page_id"],))]
|
|
123
|
+
for cid in old:
|
|
124
|
+
self.db.execute("DELETE FROM chunks_fts WHERE chunk_id=?", (cid,))
|
|
125
|
+
self.db.execute("DELETE FROM vectors WHERE chunk_id=?", (cid,))
|
|
126
|
+
self.db.execute("DELETE FROM chunks WHERE page_id=?", (page["page_id"],))
|
|
127
|
+
for i, c in enumerate(chunks):
|
|
128
|
+
self.db.execute(
|
|
129
|
+
"INSERT INTO chunks VALUES (?,?,?,?,?,?,0)",
|
|
130
|
+
(c.chunk_id, page["page_id"], c.heading_path, c.kind,
|
|
131
|
+
c.ordinal, c.text))
|
|
132
|
+
self.db.execute("INSERT INTO chunks_fts VALUES (?,?)", (c.chunk_id, c.text))
|
|
133
|
+
if vecs is not None and i < len(vecs):
|
|
134
|
+
self.db.execute(
|
|
135
|
+
"INSERT INTO vectors VALUES (?,?)",
|
|
136
|
+
(c.chunk_id, vecs[i].astype(np.float32).tobytes()))
|
|
137
|
+
|
|
138
|
+
def publish(self, seen_page_ids: set[str]) -> None:
|
|
139
|
+
"""Atomically make exactly the crawled page set the active snapshot."""
|
|
140
|
+
with self.lock, self.db:
|
|
141
|
+
self.db.execute("UPDATE pages SET active=0")
|
|
142
|
+
self.db.execute("UPDATE chunks SET active=0")
|
|
143
|
+
if seen_page_ids:
|
|
144
|
+
q = ",".join("?" * len(seen_page_ids))
|
|
145
|
+
ids = list(seen_page_ids)
|
|
146
|
+
self.db.execute(f"UPDATE pages SET active=1 WHERE page_id IN ({q})", ids)
|
|
147
|
+
self.db.execute(f"UPDATE chunks SET active=1 WHERE page_id IN ({q})", ids)
|
|
148
|
+
|
|
149
|
+
# ---- query ----
|
|
150
|
+
def active_pages(self) -> dict[str, dict]:
|
|
151
|
+
with self.lock:
|
|
152
|
+
return {r[0]: {"title": r[1], "url": r[2], "level": r[3]}
|
|
153
|
+
for r in self.db.execute(
|
|
154
|
+
"SELECT page_id,title,url,level FROM pages WHERE active=1")}
|
|
155
|
+
|
|
156
|
+
def active_chunks(self) -> list[dict]:
|
|
157
|
+
with self.lock:
|
|
158
|
+
return [{"chunk_id": r[0], "page_id": r[1], "heading": r[2],
|
|
159
|
+
"kind": r[3], "text": r[4]}
|
|
160
|
+
for r in self.db.execute(
|
|
161
|
+
"SELECT chunk_id,page_id,heading,kind,text FROM chunks WHERE active=1")]
|
|
162
|
+
|
|
163
|
+
def fts_search(self, query: str, limit: int = 30) -> list[tuple[str, float]]:
|
|
164
|
+
with self.lock:
|
|
165
|
+
try:
|
|
166
|
+
rows = self.db.execute(
|
|
167
|
+
"SELECT chunk_id, rank FROM chunks_fts WHERE chunks_fts MATCH ? "
|
|
168
|
+
"ORDER BY rank LIMIT ?",
|
|
169
|
+
(" OR ".join('"' + w.replace('"', '""') + '"'
|
|
170
|
+
for w in query.split()[:16] if w.strip()) or '""',
|
|
171
|
+
limit)).fetchall()
|
|
172
|
+
except sqlite3.OperationalError:
|
|
173
|
+
return []
|
|
174
|
+
active = {c["chunk_id"] for c in self.active_chunks()}
|
|
175
|
+
return [(r[0], -r[1]) for r in rows if r[0] in active]
|
|
176
|
+
|
|
177
|
+
def vectors_for(self, chunk_ids: list[str]) -> dict[str, np.ndarray]:
|
|
178
|
+
if not chunk_ids:
|
|
179
|
+
return {}
|
|
180
|
+
q = ",".join("?" * len(chunk_ids))
|
|
181
|
+
with self.lock:
|
|
182
|
+
return {cid: np.frombuffer(b, dtype=np.float32)
|
|
183
|
+
for cid, b in self.db.execute(
|
|
184
|
+
f"SELECT chunk_id,vec FROM vectors WHERE chunk_id IN ({q})",
|
|
185
|
+
chunk_ids)}
|
|
186
|
+
|
|
187
|
+
def stats(self) -> dict:
|
|
188
|
+
with self.lock:
|
|
189
|
+
p = self.db.execute("SELECT COUNT(*) FROM pages WHERE active=1").fetchone()[0]
|
|
190
|
+
c = self.db.execute("SELECT COUNT(*) FROM chunks WHERE active=1").fetchone()[0]
|
|
191
|
+
snap = self.db.execute(
|
|
192
|
+
"SELECT MAX(fetched_at) FROM pages WHERE active=1").fetchone()[0]
|
|
193
|
+
return {"pages": p, "chunks": c, "as_of": snap}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"comment": "Pinned local model artifacts. Embeddings and reranking are local-only at runtime; cloud embedding APIs are never used. Both models are Apache-2.0.",
|
|
3
|
+
"models": {
|
|
4
|
+
"embed": {
|
|
5
|
+
"repo": "sentence-transformers/all-MiniLM-L6-v2",
|
|
6
|
+
"revision": "e9a7bebe3474e74e8193da1a05abb5b5291dcb6a",
|
|
7
|
+
"license": "Apache-2.0"
|
|
8
|
+
},
|
|
9
|
+
"rerank": {
|
|
10
|
+
"repo": "cross-encoder/ms-marco-MiniLM-L6-v2",
|
|
11
|
+
"revision": "233902d25c440f23af6f7d6e94d2946bac0bee0a",
|
|
12
|
+
"license": "Apache-2.0"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""IDE MCP registration — writes the config files itself.
|
|
2
|
+
|
|
3
|
+
The agent can't click IDE settings for the user, but it CAN run this command:
|
|
4
|
+
we write the mcp.json files directly wherever Copilot looks. The command path
|
|
5
|
+
is `sys.executable` — always the venv interpreter that has the package.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .settings import SetupError
|
|
17
|
+
|
|
18
|
+
SERVER_ID = "confluence-knowledge"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _server_entry(workdir: Path | None) -> dict:
|
|
22
|
+
entry = {
|
|
23
|
+
"command": sys.executable, # the venv python — no PATH guessing
|
|
24
|
+
"args": ["-m", "confluence_knowledge.server"],
|
|
25
|
+
}
|
|
26
|
+
if workdir is not None:
|
|
27
|
+
entry["env"] = {"CONF_KNOWLEDGE_WORKDIR": str(workdir)}
|
|
28
|
+
return entry
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _platform_appdata() -> Path | None:
|
|
32
|
+
"""Where per-app support data lives, per OS."""
|
|
33
|
+
if os.name == "nt":
|
|
34
|
+
base = os.environ.get("LOCALAPPDATA")
|
|
35
|
+
return Path(base) if base else Path.home() / "AppData" / "Local"
|
|
36
|
+
if sys.platform == "darwin":
|
|
37
|
+
return Path.home() / "Library" / "Application Support"
|
|
38
|
+
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _jetbrains_mcp() -> Path:
|
|
42
|
+
"""The user-level config the JetBrains Copilot plugin actually reads —
|
|
43
|
+
the one we verified on a real Windows machine during field testing."""
|
|
44
|
+
return _platform_appdata() / "github-copilot" / "intellij" / "mcp.json"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _vscode_user_mcp() -> Path | None:
|
|
48
|
+
"""User-level VS Code mcp.json — only if the profile dir exists."""
|
|
49
|
+
for code in ("Code", "Code - Insiders", "VSCodium"):
|
|
50
|
+
d = _platform_appdata() / code / "User"
|
|
51
|
+
if d.is_dir():
|
|
52
|
+
return d / "mcp.json"
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _workspace_mcp(workspace: Path | None) -> Path | None:
|
|
57
|
+
return (workspace / ".vscode" / "mcp.json") if workspace else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def targets(ide: str = "auto", workspace: Path | None = None) -> list[Path]:
|
|
61
|
+
"""Every config file the chosen IDEs read. `auto` = all that apply."""
|
|
62
|
+
out = []
|
|
63
|
+
if ide in ("auto", "all", "jetbrains"):
|
|
64
|
+
out.append(_jetbrains_mcp())
|
|
65
|
+
if ide in ("auto", "all", "vscode"):
|
|
66
|
+
v = _vscode_user_mcp()
|
|
67
|
+
if v:
|
|
68
|
+
out.append(v)
|
|
69
|
+
w = _workspace_mcp(workspace)
|
|
70
|
+
if w:
|
|
71
|
+
out.append(w)
|
|
72
|
+
# de-dupe (user + workspace can resolve to the same file in tests)
|
|
73
|
+
seen, dedup = set(), []
|
|
74
|
+
for p in out:
|
|
75
|
+
if p not in seen:
|
|
76
|
+
seen.add(p)
|
|
77
|
+
dedup.append(p)
|
|
78
|
+
return dedup
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _merge(path: Path, entry: dict | None) -> str:
|
|
82
|
+
"""Write `entry` under servers[SERVER_ID], or remove it when None.
|
|
83
|
+
|
|
84
|
+
Merges with existing JSON; backs up before overwriting. A file that isn't
|
|
85
|
+
parseable (JSONC comments etc.) is backed up and replaced — the agent
|
|
86
|
+
tells the user to re-add their other servers.
|
|
87
|
+
"""
|
|
88
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
data: dict = {}
|
|
90
|
+
replaced = False
|
|
91
|
+
if path.exists():
|
|
92
|
+
try:
|
|
93
|
+
data = json.loads(path.read_text())
|
|
94
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
95
|
+
data = {}
|
|
96
|
+
replaced = True # unparseable (JSONC etc.) — back up and rewrite
|
|
97
|
+
shutil.copy2(path, path.with_suffix(path.suffix + ".bak"))
|
|
98
|
+
servers = data.setdefault("servers", {})
|
|
99
|
+
if entry is None:
|
|
100
|
+
servers.pop(SERVER_ID, None)
|
|
101
|
+
action = "removed"
|
|
102
|
+
else:
|
|
103
|
+
servers[SERVER_ID] = entry
|
|
104
|
+
action = "backup-replaced" if replaced else "written"
|
|
105
|
+
tmp = path.with_suffix(".tmp")
|
|
106
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n")
|
|
107
|
+
os.replace(tmp, path)
|
|
108
|
+
return action
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def register(ide: str = "auto", workspace: Path | None = None,
|
|
112
|
+
workdir: Path | None = None, remove: bool = False) -> list[tuple[Path, str]]:
|
|
113
|
+
"""Write/remove our server entry in each target. Returns (path, action)."""
|
|
114
|
+
if ide not in ("auto", "all", "vscode", "jetbrains"):
|
|
115
|
+
raise SetupError(f"unknown --ide '{ide}' (auto|all|vscode|jetbrains)")
|
|
116
|
+
files = targets(ide, workspace)
|
|
117
|
+
if not files:
|
|
118
|
+
raise SetupError("no IDE config locations found — no Copilot/JetBrains "
|
|
119
|
+
"or VS Code profile detected")
|
|
120
|
+
entry = None if remove else _server_entry(workdir)
|
|
121
|
+
return [(f, _merge(f, entry)) for f in files]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def registered_paths() -> list[Path]:
|
|
125
|
+
"""Existing config files that already contain our server — for doctor."""
|
|
126
|
+
out = []
|
|
127
|
+
for p in targets("all"):
|
|
128
|
+
try:
|
|
129
|
+
servers = json.loads(p.read_text()).get("servers", {})
|
|
130
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
131
|
+
continue
|
|
132
|
+
if p.exists() and SERVER_ID in servers:
|
|
133
|
+
out.append(p)
|
|
134
|
+
return out
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Local hybrid retrieval: vectors + FTS → RRF → local cross-encoder rerank.
|
|
2
|
+
|
|
3
|
+
Embeddings and reranking run exclusively on this machine. No text ever leaves
|
|
4
|
+
the process for retrieval. Models load local_files_only with trust_remote_code
|
|
5
|
+
disabled; a missing model is a setup error, never a silent fallback.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from .index import Index
|
|
17
|
+
from .settings import SetupError
|
|
18
|
+
|
|
19
|
+
EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2" # Apache-2.0
|
|
20
|
+
RERANK_MODEL = "cross-encoder/ms-marco-MiniLM-L6-v2" # Apache-2.0
|
|
21
|
+
|
|
22
|
+
TOP_K_RETRIEVE = 30
|
|
23
|
+
TOP_K_EVIDENCE = 8
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _models_dir(home: Path) -> Path:
|
|
27
|
+
return home / "models"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_sentence_transformer(model_key: str, home: Path):
|
|
31
|
+
"""Local-only model load — fails closed if artifacts aren't prepared."""
|
|
32
|
+
try:
|
|
33
|
+
from sentence_transformers import SentenceTransformer
|
|
34
|
+
except ImportError as e:
|
|
35
|
+
raise SetupError(
|
|
36
|
+
"sentence-transformers isn't installed — run "
|
|
37
|
+
"`confluence-knowledge prepare-models` (see docs/confluence-knowledge.md)"
|
|
38
|
+
) from e
|
|
39
|
+
local = _models_dir(home) / model_key.split("/")[-1]
|
|
40
|
+
if not local.exists():
|
|
41
|
+
raise SetupError(
|
|
42
|
+
f"local model '{model_key}' not prepared — run "
|
|
43
|
+
"`confluence-knowledge prepare-models`. Cloud embeddings are never used."
|
|
44
|
+
)
|
|
45
|
+
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
|
|
46
|
+
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
|
47
|
+
os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
|
|
48
|
+
return SentenceTransformer(
|
|
49
|
+
str(local), local_files_only=True, trust_remote_code=False,
|
|
50
|
+
device="cpu")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LocalEmbedder:
|
|
54
|
+
def __init__(self, home: Path):
|
|
55
|
+
self._m = _load_sentence_transformer(EMBED_MODEL, home)
|
|
56
|
+
|
|
57
|
+
def embed(self, texts: list[str]) -> np.ndarray:
|
|
58
|
+
v = self._m.encode(texts, convert_to_numpy=True, normalize_embeddings=True,
|
|
59
|
+
show_progress_bar=False)
|
|
60
|
+
return np.asarray(v, dtype=np.float32)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LocalReranker:
|
|
64
|
+
def __init__(self, home: Path):
|
|
65
|
+
try:
|
|
66
|
+
from sentence_transformers import CrossEncoder
|
|
67
|
+
except ImportError as e:
|
|
68
|
+
raise SetupError(
|
|
69
|
+
"sentence-transformers isn't installed — run "
|
|
70
|
+
"`confluence-knowledge prepare-models`"
|
|
71
|
+
) from e
|
|
72
|
+
local = _models_dir(home) / RERANK_MODEL.split("/")[-1]
|
|
73
|
+
if not local.exists():
|
|
74
|
+
raise SetupError(
|
|
75
|
+
f"local reranker '{RERANK_MODEL}' not prepared — run "
|
|
76
|
+
"`confluence-knowledge prepare-models`.")
|
|
77
|
+
self._m = CrossEncoder(str(local), device="cpu",
|
|
78
|
+
trust_remote_code=False)
|
|
79
|
+
|
|
80
|
+
def score(self, query: str, docs: list[str]) -> np.ndarray:
|
|
81
|
+
return np.asarray(
|
|
82
|
+
self._m.predict([[query, d] for d in docs], show_progress_bar=False),
|
|
83
|
+
dtype=np.float32)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def prepare_models(home: Path) -> list[str]:
|
|
87
|
+
"""Download pinned models once (public artifacts; no document text sent)."""
|
|
88
|
+
from huggingface_hub import snapshot_download
|
|
89
|
+
|
|
90
|
+
out = []
|
|
91
|
+
lock = _load_lock()
|
|
92
|
+
for key, repo in (("embed", EMBED_MODEL), ("rerank", RERANK_MODEL)):
|
|
93
|
+
rev = lock["models"][key]["revision"]
|
|
94
|
+
dest = _models_dir(home) / repo.split("/")[-1]
|
|
95
|
+
if dest.exists():
|
|
96
|
+
# treat partial/interrupted downloads as not prepared: a finished
|
|
97
|
+
# dir has config + model weights + tokenizer files
|
|
98
|
+
complete = (dest / "config.json").exists() and (
|
|
99
|
+
any(dest.glob("*.safetensors")) or any(dest.rglob("*.safetensors")))
|
|
100
|
+
if complete:
|
|
101
|
+
out.append(f"already prepared: {repo}@{rev[:8]}")
|
|
102
|
+
continue
|
|
103
|
+
shutil.rmtree(dest) # incomplete — fetch again
|
|
104
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
snapshot_download(repo_id=repo, revision=rev, local_dir=dest,
|
|
106
|
+
# safetensors + tokenizer/config only: the ONNX and
|
|
107
|
+
# OpenVINO variants are ~5x the size and never loaded
|
|
108
|
+
allow_patterns=["*.json", "*.txt", "*.safetensors",
|
|
109
|
+
"*.md", "LICENSE*",
|
|
110
|
+
"*.model", "*.spm", "modules.json"],
|
|
111
|
+
ignore_patterns=["*.bin", "*.msgpack", "*.h5",
|
|
112
|
+
"*.pt", "*.ot"])
|
|
113
|
+
if not _license_ok(dest):
|
|
114
|
+
raise SetupError(f"{repo} license artifact missing — refusing to ship")
|
|
115
|
+
out.append(f"prepared {repo}@{rev[:8]} (Apache-2.0)")
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _license_ok(dest: Path) -> bool:
|
|
120
|
+
"""LICENSE* file present, or the model card declares a permissive license."""
|
|
121
|
+
if any(dest.glob("LICENSE*")):
|
|
122
|
+
return True
|
|
123
|
+
for md in dest.glob("*.md"):
|
|
124
|
+
head = md.read_text(errors="ignore")[:6000].lower()
|
|
125
|
+
if "license: apache-2.0" in head or "license: mit" in head:
|
|
126
|
+
return True
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _load_lock() -> dict:
|
|
131
|
+
import json
|
|
132
|
+
|
|
133
|
+
p = Path(__file__).parent / "models.lock.json" # shipped inside the package
|
|
134
|
+
if p.exists():
|
|
135
|
+
return json.loads(p.read_text())
|
|
136
|
+
# package-data should always be present; refuse rather than float to main
|
|
137
|
+
raise SetupError("models.lock.json missing from package — reinstall the service")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _cosine(q: np.ndarray, mat: dict[str, np.ndarray]) -> list[tuple[str, float]]:
|
|
141
|
+
if not mat:
|
|
142
|
+
return []
|
|
143
|
+
ids = list(mat)
|
|
144
|
+
m = np.stack([mat[i] for i in ids])
|
|
145
|
+
m = m / (np.linalg.norm(m, axis=1, keepdims=True) + 1e-9)
|
|
146
|
+
q = q / (np.linalg.norm(q) + 1e-9)
|
|
147
|
+
return sorted(zip(ids, (m @ q).tolist()), key=lambda t: -t[1])
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def retrieve(index: Index, query: str, embedder: LocalEmbedder,
|
|
151
|
+
reranker: LocalReranker | None = None) -> list[dict]:
|
|
152
|
+
"""Hybrid retrieve → RRF fuse → rerank → bounded evidence."""
|
|
153
|
+
chunks = index.active_chunks()
|
|
154
|
+
if not chunks:
|
|
155
|
+
return []
|
|
156
|
+
|
|
157
|
+
# vector leg
|
|
158
|
+
qvec = embedder.embed([query])[0]
|
|
159
|
+
vecs = index.vectors_for([c["chunk_id"] for c in chunks])
|
|
160
|
+
vec_rank = [cid for cid, _ in _cosine(qvec, vecs)][:TOP_K_RETRIEVE]
|
|
161
|
+
|
|
162
|
+
# keyword leg
|
|
163
|
+
fts_rank = [cid for cid, _ in index.fts_search(query)][:TOP_K_RETRIEVE]
|
|
164
|
+
|
|
165
|
+
# reciprocal-rank fusion
|
|
166
|
+
rrf: dict[str, float] = {}
|
|
167
|
+
for leg in (vec_rank, fts_rank):
|
|
168
|
+
for rank, cid in enumerate(leg):
|
|
169
|
+
rrf[cid] = rrf.get(cid, 0.0) + 1.0 / (60 + rank + 1)
|
|
170
|
+
fused = sorted(rrf, key=lambda c: -rrf[c])[:TOP_K_RETRIEVE]
|
|
171
|
+
by_id = {c["chunk_id"]: c for c in chunks}
|
|
172
|
+
candidates = [by_id[c] for c in fused if c in by_id]
|
|
173
|
+
|
|
174
|
+
if reranker is not None and candidates:
|
|
175
|
+
scores = reranker.score(query, [c["text"] for c in candidates])
|
|
176
|
+
order = np.argsort(-scores)[:TOP_K_EVIDENCE]
|
|
177
|
+
evidence = []
|
|
178
|
+
for i in order:
|
|
179
|
+
c = dict(candidates[i])
|
|
180
|
+
c["rerank_score"] = float(scores[i])
|
|
181
|
+
evidence.append(c)
|
|
182
|
+
else:
|
|
183
|
+
evidence = [dict(c, rerank_score=None) for c in candidates[:TOP_K_EVIDENCE]]
|
|
184
|
+
|
|
185
|
+
pages = index.active_pages()
|
|
186
|
+
for e in evidence:
|
|
187
|
+
p = pages.get(e["page_id"], {})
|
|
188
|
+
e["page_title"], e["page_url"], e["level"] = (
|
|
189
|
+
p.get("title", ""), p.get("url", ""), p.get("level"))
|
|
190
|
+
return evidence
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def check_citations(evidence: list[dict], mappings: list[dict]) -> dict:
|
|
194
|
+
"""Verify claim→chunk citations: chunk exists in this evidence set and the
|
|
195
|
+
claimed quote is literally present in it. Provenance, not entailment."""
|
|
196
|
+
by_id = {e["chunk_id"]: e for e in evidence}
|
|
197
|
+
bad = []
|
|
198
|
+
for m in mappings:
|
|
199
|
+
ch = by_id.get(m.get("chunk_id", ""))
|
|
200
|
+
if ch is None:
|
|
201
|
+
bad.append({"claim": m.get("claim", "")[:80], "reason": "chunk not in evidence set"})
|
|
202
|
+
continue
|
|
203
|
+
quote = m.get("quote", "")
|
|
204
|
+
if quote and quote not in ch["text"]:
|
|
205
|
+
bad.append({"claim": m.get("claim", "")[:80],
|
|
206
|
+
"reason": "quote not present verbatim in cited chunk"})
|
|
207
|
+
return {"ok": not bad, "violations": bad}
|