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.
@@ -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}