nikeshn commited on
Commit
a2416df
Β·
verified Β·
1 Parent(s): 7dfd4df

Upload 8 files

Browse files
src/services/cache_service.py CHANGED
@@ -27,9 +27,9 @@ class CacheService:
27
  return None
28
  return value
29
 
30
- def set(self, key: str, value: Any, ttl: int = 3600) -> None:
31
- self._prune()
32
- self._cache[key] = (value, time.time() + ttl)
33
 
34
  def clear(self) -> None:
35
  self._cache.clear()
 
27
  return None
28
  return value
29
 
30
+ def set(self, key: str, value: Any, ttl: int = 3600) -> None:
31
+ self._cache[key] = (value, time.time() + ttl)
32
+ self._prune()
33
 
34
  def clear(self) -> None:
35
  self._cache.clear()
src/services/html_sanitizer.py CHANGED
@@ -8,7 +8,8 @@ from urllib.parse import urlparse
8
 
9
 
10
  ALLOWED_TAGS = {"a", "b", "blockquote", "br", "code", "details", "div", "em", "h3", "h4", "hr", "i", "li", "ol", "p", "pre", "span", "strong", "summary", "table", "tbody", "td", "th", "thead", "tr", "ul"}
11
- VOID_TAGS = {"br", "hr"}
 
12
  DROP_CONTENT_TAGS = {"button", "form", "iframe", "input", "math", "object", "script", "style", "svg"}
13
  GLOBAL_ATTRIBUTES = {"aria-controls", "aria-expanded", "aria-label", "class", "role"}
14
  TAG_ATTRIBUTES = {"a": {"href", "rel", "target", "title"}, "td": {"colspan", "rowspan"}, "th": {"colspan", "rowspan", "scope"}}
@@ -21,14 +22,27 @@ _MARKDOWN_LINK_RE = re.compile(
21
 
22
 
23
  def _normalise_url(value: str) -> str:
24
- return unescape(value).strip().rstrip(".,;:!?)]}")
 
 
25
 
26
 
27
  def _safe_href(value: str, allowed_urls: set[str] | None = None) -> str | None:
28
  candidate = _normalise_url(value)
29
  if not candidate:
30
  return None
31
- parsed = urlparse(candidate)
 
 
 
 
 
 
 
 
 
 
 
32
  if parsed.scheme.lower() not in {"https", "mailto", "tel"}:
33
  return None
34
  if allowed_urls is not None and candidate not in allowed_urls:
@@ -64,9 +78,10 @@ class _Sanitizer(HTMLParser):
64
  )
65
 
66
  def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
67
- tag = tag.lower()
68
- if tag in DROP_CONTENT_TAGS:
69
- self.drop_depth += 1
 
70
  return
71
  if self.drop_depth or tag not in ALLOWED_TAGS:
72
  return
@@ -90,12 +105,14 @@ class _Sanitizer(HTMLParser):
90
  rendered.append(' rel="noopener noreferrer"')
91
  self.output.append(f"<{tag}{''.join(rendered)}>")
92
 
93
- def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
94
- self.handle_starttag(tag, attrs)
 
 
95
 
96
  def handle_endtag(self, tag: str) -> None:
97
  tag = tag.lower()
98
- if tag in DROP_CONTENT_TAGS:
99
  if self.drop_depth:
100
  self.drop_depth -= 1
101
  return
@@ -106,7 +123,10 @@ class _Sanitizer(HTMLParser):
106
  if not self.drop_depth:
107
  if self.allowed_urls is not None:
108
  data = _VISIBLE_URL_RE.sub(
109
- lambda m: m.group(0) if _normalise_url(m.group(0)) in self.allowed_urls else "",
 
 
 
110
  data,
111
  )
112
  self.output.append(escape(data, quote=False))
 
8
 
9
 
10
  ALLOWED_TAGS = {"a", "b", "blockquote", "br", "code", "details", "div", "em", "h3", "h4", "hr", "i", "li", "ol", "p", "pre", "span", "strong", "summary", "table", "tbody", "td", "th", "thead", "tr", "ul"}
11
+ VOID_TAGS = {"br", "hr"}
12
+ HTML_VOID_TAGS = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"}
13
  DROP_CONTENT_TAGS = {"button", "form", "iframe", "input", "math", "object", "script", "style", "svg"}
14
  GLOBAL_ATTRIBUTES = {"aria-controls", "aria-expanded", "aria-label", "class", "role"}
15
  TAG_ATTRIBUTES = {"a": {"href", "rel", "target", "title"}, "td": {"colspan", "rowspan"}, "th": {"colspan", "rowspan", "scope"}}
 
22
 
23
 
24
  def _normalise_url(value: str) -> str:
25
+ # Punctuation can be meaningful in a URL, especially article titles and
26
+ # signed query values. Only strip sentence punctuation when matching text.
27
+ return unescape(value).strip()
28
 
29
 
30
  def _safe_href(value: str, allowed_urls: set[str] | None = None) -> str | None:
31
  candidate = _normalise_url(value)
32
  if not candidate:
33
  return None
34
+ if any(character.isspace() or ord(character) < 32 or ord(character) == 127 for character in candidate):
35
+ return None
36
+ try:
37
+ parsed = urlparse(candidate)
38
+ if parsed.scheme.lower() == "https" and (
39
+ not parsed.hostname or parsed.username or parsed.password
40
+ ):
41
+ return None
42
+ # Evaluating a malformed authority/port may itself raise ValueError.
43
+ _ = parsed.port
44
+ except ValueError:
45
+ return None
46
  if parsed.scheme.lower() not in {"https", "mailto", "tel"}:
47
  return None
48
  if allowed_urls is not None and candidate not in allowed_urls:
 
78
  )
79
 
80
  def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
81
+ tag = tag.lower()
82
+ if tag in DROP_CONTENT_TAGS:
83
+ if tag not in HTML_VOID_TAGS:
84
+ self.drop_depth += 1
85
  return
86
  if self.drop_depth or tag not in ALLOWED_TAGS:
87
  return
 
105
  rendered.append(' rel="noopener noreferrer"')
106
  self.output.append(f"<{tag}{''.join(rendered)}>")
107
 
108
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
109
+ self.handle_starttag(tag, attrs)
110
+ if tag.lower() not in HTML_VOID_TAGS:
111
+ self.handle_endtag(tag)
112
 
113
  def handle_endtag(self, tag: str) -> None:
114
  tag = tag.lower()
115
+ if tag in DROP_CONTENT_TAGS and tag not in HTML_VOID_TAGS:
116
  if self.drop_depth:
117
  self.drop_depth -= 1
118
  return
 
123
  if not self.drop_depth:
124
  if self.allowed_urls is not None:
125
  data = _VISIBLE_URL_RE.sub(
126
+ lambda m: m.group(0) if (
127
+ _normalise_url(m.group(0)) in self.allowed_urls or
128
+ _normalise_url(m.group(0)).rstrip(".,;:!?)]}") in self.allowed_urls
129
+ ) else "",
130
  data,
131
  )
132
  self.output.append(escape(data, quote=False))
src/services/metrics_service.py CHANGED
@@ -7,15 +7,16 @@ Fixes applied:
7
  2. flush() merges in-memory state to disk atomically using a temp-file rename.
8
  Called by a background task in app.py every 30 seconds and on shutdown.
9
  3. threading.Lock replaced with asyncio.Lock β€” consistent with FastAPI's async model.
10
- 4. _read() logs a warning (with raw content) on JSON parse failure instead of
11
- silently returning zeros, so corrupt files are visible in logs.
12
- 5. _write() uses os.replace (atomic rename) to prevent corrupt state on crash.
13
  """
14
  import asyncio
15
  import json
16
  import logging
17
  import os
18
- import time
 
19
  from pathlib import Path
20
  from typing import Any, Dict
21
 
@@ -52,7 +53,7 @@ class MetricsService:
52
 
53
  # Seed disk file if it doesn't exist
54
  if not self.path.exists():
55
- self._write_to_disk(dict(_DEFAULT_STATE))
56
 
57
  # ── Public API (sync, no I/O) ──────────────────────────────────────────────
58
 
@@ -69,7 +70,7 @@ class MetricsService:
69
  """Append a query record to the in-memory log (capped at _MAX_QUERY_LOG)."""
70
  import datetime
71
  record = {
72
- "timestamp": datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
73
  "question": (question or "")[:120],
74
  "intent": intent or "",
75
  "tool": tool or "",
@@ -82,9 +83,11 @@ class MetricsService:
82
  if len(self._query_log) > _MAX_QUERY_LOG:
83
  self._query_log = self._query_log[-_MAX_QUERY_LOG:]
84
 
85
- def recent_queries(self, limit: int = 50) -> list:
86
- """Return the most recent `limit` query records, newest first."""
87
- return list(reversed(self._query_log[-limit:]))
 
 
88
 
89
  # ── Public API (sync, no I/O) ──────────────────────────────────────────────
90
 
@@ -128,7 +131,9 @@ class MetricsService:
128
  b = data.setdefault(bucket, {})
129
  for k, v in keys.items():
130
  b[k] = int(b.get(k, 0)) + v
131
- self._write_to_disk(data)
 
 
132
  self._counters.clear()
133
  self._buckets.clear()
134
  self._dirty = False
@@ -137,32 +142,44 @@ class MetricsService:
137
 
138
  def _read_from_disk(self) -> Dict[str, Any]:
139
  if not self.path.exists():
140
- return dict(_DEFAULT_STATE)
141
- try:
142
- return json.loads(self.path.read_text(encoding="utf-8"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  except Exception as exc:
144
- raw = ""
145
- try:
146
- raw = self.path.read_text(encoding="utf-8", errors="replace")[:200]
147
- except Exception:
148
- pass
149
- logger.warning(
150
- "MetricsService: failed to parse metrics file %s: %s | raw: %r",
151
- self.path, exc, raw,
152
- )
153
- return dict(_DEFAULT_STATE)
154
-
155
- def _write_to_disk(self, data: Dict[str, Any]) -> None:
156
  data["last_updated"] = time.time()
157
  tmp = self.path.with_suffix(".tmp")
158
  try:
159
  tmp.write_text(
160
  json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
161
  )
162
- os.replace(tmp, self.path) # atomic on POSIX / Windows
 
163
  except Exception as exc:
164
  logger.error("MetricsService: failed to write metrics: %s", exc)
165
  try:
166
  tmp.unlink(missing_ok=True)
167
- except Exception:
168
- pass
 
 
7
  2. flush() merges in-memory state to disk atomically using a temp-file rename.
8
  Called by a background task in app.py every 30 seconds and on shutdown.
9
  3. threading.Lock replaced with asyncio.Lock β€” consistent with FastAPI's async model.
10
+ 4. Corrupt metrics are reported without copying file contents into logs.
11
+ 5. _write() uses os.replace (atomic rename) to prevent corrupt state on crash.
12
+ 6. Failed writes retain pending counts for the next flush.
13
  """
14
  import asyncio
15
  import json
16
  import logging
17
  import os
18
+ import time
19
+ from copy import deepcopy
20
  from pathlib import Path
21
  from typing import Any, Dict
22
 
 
53
 
54
  # Seed disk file if it doesn't exist
55
  if not self.path.exists():
56
+ self._write_to_disk(deepcopy(_DEFAULT_STATE))
57
 
58
  # ── Public API (sync, no I/O) ──────────────────────────────────────────────
59
 
 
70
  """Append a query record to the in-memory log (capped at _MAX_QUERY_LOG)."""
71
  import datetime
72
  record = {
73
+ "timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
74
  "question": (question or "")[:120],
75
  "intent": intent or "",
76
  "tool": tool or "",
 
83
  if len(self._query_log) > _MAX_QUERY_LOG:
84
  self._query_log = self._query_log[-_MAX_QUERY_LOG:]
85
 
86
+ def recent_queries(self, limit: int = 50) -> list:
87
+ """Return the most recent `limit` query records, newest first."""
88
+ if limit <= 0:
89
+ return []
90
+ return list(reversed(self._query_log[-limit:]))
91
 
92
  # ── Public API (sync, no I/O) ──────────────────────────────────────────────
93
 
 
131
  b = data.setdefault(bucket, {})
132
  for k, v in keys.items():
133
  b[k] = int(b.get(k, 0)) + v
134
+ if not self._write_to_disk(data):
135
+ # Preserve pending counts so a later successful flush can retry.
136
+ return
137
  self._counters.clear()
138
  self._buckets.clear()
139
  self._dirty = False
 
142
 
143
  def _read_from_disk(self) -> Dict[str, Any]:
144
  if not self.path.exists():
145
+ return deepcopy(_DEFAULT_STATE)
146
+ try:
147
+ loaded = json.loads(self.path.read_text(encoding="utf-8"))
148
+ if not isinstance(loaded, dict):
149
+ raise ValueError("metrics state must be a JSON object")
150
+ data = deepcopy(_DEFAULT_STATE)
151
+ for key, value in loaded.items():
152
+ if isinstance(value, dict):
153
+ if key in _DEFAULT_STATE and not isinstance(_DEFAULT_STATE[key], dict):
154
+ continue
155
+ data[key] = {
156
+ str(k): v for k, v in value.items()
157
+ if isinstance(v, int) and not isinstance(v, bool) and v >= 0
158
+ }
159
+ elif isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0:
160
+ if key not in {"intents", "errors"}:
161
+ data[key] = value
162
+ return data
163
  except Exception as exc:
164
+ logger.warning(
165
+ "MetricsService: failed to parse metrics file %s: %s",
166
+ self.path, exc,
167
+ )
168
+ return deepcopy(_DEFAULT_STATE)
169
+
170
+ def _write_to_disk(self, data: Dict[str, Any]) -> bool:
 
 
 
 
 
171
  data["last_updated"] = time.time()
172
  tmp = self.path.with_suffix(".tmp")
173
  try:
174
  tmp.write_text(
175
  json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
176
  )
177
+ os.replace(tmp, self.path) # atomic on POSIX / Windows
178
+ return True
179
  except Exception as exc:
180
  logger.error("MetricsService: failed to write metrics: %s", exc)
181
  try:
182
  tmp.unlink(missing_ok=True)
183
+ except Exception:
184
+ pass
185
+ return False
src/services/rag_service.py CHANGED
@@ -1,69 +1,67 @@
1
- """
2
- rag_service.py β€” LibBee v3.1
3
-
4
- Fixes applied:
5
- 1. _find_corpus_index replaced with O(1) reverse-lookup dict (_content_to_idx).
6
- Old implementation was O(n * fetch_k) per query β€” a major bottleneck.
7
- 2. fetch_k corrected: max(top_k * 3, 15) β€” old expression max(top_k*3, top_k)
8
- was always top_k*3 (max was a no-op). Now enforces a floor of 15.
9
- 3. _content_to_idx rebuilt whenever corpus is loaded (init, cache load, rebuild).
10
- 4. audit_knowledge_base removed from get_stats() β€” now only called explicitly
11
- via a dedicated audit() method to avoid disk reads on every /rag-status poll.
12
- 5. _write / cache saves use os.replace for atomic writes.
13
- """
14
  import hashlib
15
  import hmac
16
- import json
17
- import logging
18
- import os
19
- import re
20
- import time
21
- from pathlib import Path
22
- from typing import List, Optional
23
-
24
- import numpy as np
25
-
26
- from src.config import get_settings
27
-
28
- logger = logging.getLogger(__name__)
29
  CHUNK_WORDS = 260
30
  CHUNK_OVERLAP_WORDS = 40
31
- TOKEN_RE = re.compile(r"[a-z0-9][a-z0-9_-]{1,}")
 
 
32
  STOP_WORDS = {"about", "and", "are", "can", "for", "from", "how", "library", "please", "that", "the", "this", "what", "when", "where", "which", "with", "you"}
33
-
34
-
35
- class RAGService:
36
- def __init__(self):
37
- self.vectorstore = None
38
- self.bm25 = None
39
- self.bm25_corpus: List[str] = []
40
- self.bm25_meta: List[dict] = []
41
- self._embeddings = None
42
- self._ready = False
43
- self._kb_hash = ""
44
- # O(1) reverse lookup: chunk_text -> corpus index
45
- self._content_to_idx: dict[str, int] = {}
46
-
47
- def is_ready(self) -> bool:
48
- return self._ready
49
-
50
- def _knowledge_dir(self) -> Path:
51
- return get_settings().kb_dir
52
-
53
- def _cache_dir(self) -> Path:
54
- return get_settings().rag_cache_dir
55
-
56
- def _hash_knowledge_base(self, txt_files: List[Path]) -> str:
57
- digest = hashlib.sha256()
58
- for path in txt_files:
59
- stat = path.stat()
60
- digest.update(path.name.encode("utf-8"))
61
- digest.update(str(stat.st_mtime_ns).encode("utf-8"))
62
- digest.update(str(stat.st_size).encode("utf-8"))
63
- return digest.hexdigest()
64
-
 
 
 
 
 
65
  def _cache_paths(self) -> tuple[Path, Path, Path]:
66
- cache_dir = self._cache_dir()
67
  return cache_dir / "faiss_index", cache_dir / "bm25_cache.json", cache_dir / "kb_state.json"
68
 
69
  def _cache_signature(self, faiss_dir: Path, bm25_path: Path) -> str:
@@ -79,259 +77,261 @@ class RAGService:
79
 
80
  @staticmethod
81
  def _tokens(value: str) -> List[str]:
82
- return TOKEN_RE.findall((value or "").lower())
83
-
84
- def _build_reverse_index(self) -> None:
85
- """Build O(1) content -> index lookup. Call after any corpus change."""
86
- self._content_to_idx = {
87
- chunk.strip(): i for i, chunk in enumerate(self.bm25_corpus)
88
- }
89
-
90
- async def initialize(self, openai_api_key: str) -> None:
91
- t0 = time.time()
92
- logger.info("RAGService: starting initialization")
93
- settings = get_settings()
94
- knowledge_dir = settings.kb_dir
95
- txt_files = sorted(knowledge_dir.glob("*.txt")) if knowledge_dir.exists() else []
96
- self._kb_hash = self._hash_knowledge_base(txt_files) if txt_files else ""
97
-
98
- try:
99
- from langchain_openai import OpenAIEmbeddings
100
- from langchain_community.vectorstores import FAISS
101
- from rank_bm25 import BM25Okapi
102
-
103
- self._embeddings = OpenAIEmbeddings(
104
- model="text-embedding-3-small", openai_api_key=openai_api_key
105
- )
106
- if self._try_load_cached_indexes(FAISS, BM25Okapi):
107
- self._build_reverse_index()
108
- self._ready = True
109
- logger.info(
110
- "RAGService: loaded cached indexes β€” %d chunks", len(self.bm25_corpus)
111
- )
112
- return
113
-
114
- chunks, metadatas = self._load_and_chunk_all()
115
- if not chunks:
116
- logger.error("RAGService: no chunks loaded β€” check knowledge_dir: %s", knowledge_dir)
117
- return
118
-
119
- self.vectorstore = await FAISS.afrom_texts(
120
- chunks, self._embeddings, metadatas=metadatas
121
- )
122
- self.bm25_corpus = chunks
123
- self.bm25_meta = metadatas
124
- tokenized = [self._tokens(c) for c in chunks]
125
- self.bm25 = BM25Okapi(tokenized)
126
- self._build_reverse_index()
127
- self._save_cached_indexes()
128
- self._ready = True
129
- logger.info(
130
- "RAGService: ready β€” %d chunks indexed in %.1fs", len(chunks), time.time() - t0
131
- )
132
- except Exception as exc:
133
- logger.error("RAGService initialization failed: %s", exc, exc_info=True)
134
-
135
- def _try_load_cached_indexes(self, FAISS, BM25Okapi) -> bool:
136
- faiss_dir, bm25_path, state_path = self._cache_paths()
137
- if not (faiss_dir.exists() and bm25_path.exists() and state_path.exists()):
138
- return False
139
- try:
140
- state = json.loads(state_path.read_text(encoding="utf-8"))
141
- if state.get("kb_hash") != self._kb_hash:
142
- logger.info("RAGService: KB changed (hash mismatch) β€” rebuilding indexes")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  return False
144
  expected = self._cache_signature(faiss_dir, bm25_path)
145
  if not expected or not hmac.compare_digest(str(state.get("signature", "")), expected):
146
- logger.warning("RAGService: unsigned or modified cache ignored")
 
 
 
147
  return False
148
- self.vectorstore = FAISS.load_local(
149
- str(faiss_dir), self._embeddings, allow_dangerous_deserialization=True
150
- )
151
- bm25_cache = json.loads(bm25_path.read_text(encoding="utf-8"))
152
- self.bm25_corpus = bm25_cache["corpus"]
153
- self.bm25_meta = bm25_cache["meta"]
154
- tokenized = [self._tokens(c) for c in self.bm25_corpus]
155
- self.bm25 = BM25Okapi(tokenized)
156
- return True
157
- except Exception as exc:
158
- logger.warning("Failed loading cached RAG index: %s β€” will rebuild", exc)
159
- return False
160
-
161
- def _save_cached_indexes(self) -> None:
162
- if not self.vectorstore:
163
- return
164
- faiss_dir, bm25_path, state_path = self._cache_paths()
165
- faiss_dir.mkdir(parents=True, exist_ok=True)
166
- self.vectorstore.save_local(str(faiss_dir))
167
-
168
- # Atomic write for bm25 cache
169
- bm25_tmp = bm25_path.with_suffix(".tmp")
170
- bm25_tmp.write_text(
171
- json.dumps(
172
- {"corpus": self.bm25_corpus, "meta": self.bm25_meta}, ensure_ascii=False
173
- ),
174
- encoding="utf-8",
175
- )
176
- os.replace(bm25_tmp, bm25_path)
177
-
178
- # Atomic write for state
179
- state_tmp = state_path.with_suffix(".tmp")
180
- state_tmp.write_text(
181
- json.dumps({"kb_hash": self._kb_hash, "chunk_count": len(self.bm25_corpus), "signature": self._cache_signature(faiss_dir, bm25_path)}),
182
- encoding="utf-8",
183
- )
184
- os.replace(state_tmp, state_path)
185
-
186
- def _load_and_chunk_all(self) -> tuple[List[str], List[dict]]:
187
- all_chunks: List[str] = []
188
- all_meta: List[dict] = []
189
- knowledge_dir = self._knowledge_dir()
190
- if not knowledge_dir.exists():
191
- logger.error("Knowledge directory not found: %s", knowledge_dir)
192
- return [], []
193
- txt_files = sorted(knowledge_dir.glob("*.txt"))
194
- for fpath in txt_files:
195
- try:
196
- text = fpath.read_text(encoding="utf-8").strip()
197
- if not text:
198
- continue
199
- source_url = ""
200
- title = fpath.stem.replace("_", " ").replace("-", " ")
201
- authority = "official"
202
- reviewed = ""
203
- for line in text.splitlines()[:8]:
204
- line = line.strip()
205
- if line.startswith("SOURCE:"):
206
- source_url = line.replace("SOURCE:", "").strip()
207
- elif line.startswith("TITLE:"):
208
- title = line.replace("TITLE:", "").strip()
209
- elif line.startswith("AUTHORITY:"):
210
- authority = line.replace("AUTHORITY:", "").strip()[:40]
211
- elif line.startswith("LAST_REVIEWED:"):
212
- reviewed = line.replace("LAST_REVIEWED:", "").strip()[:20]
213
- for chunk in self._chunk_text(text):
214
- all_chunks.append(chunk)
215
- all_meta.append(
216
- {"source": source_url, "title": title, "filename": fpath.name,
217
- "authority": authority, "last_reviewed": reviewed}
218
- )
219
- except Exception as exc:
220
- logger.warning("Failed to load %s: %s", fpath.name, exc)
221
- logger.info("RAGService: loaded %d chunks from %d files", len(all_chunks), len(txt_files))
222
- return all_chunks, all_meta
223
-
224
- def _chunk_text(self, text: str) -> List[str]:
225
- chunks: List[str] = []
226
- sections = re.split(r"\n(?=(?:TOPIC:|[A-Z][A-Z\s,/&()\-]+:)\s)", text)
227
- for section in sections:
228
- section = section.strip()
229
- if not section or len(section) < 30:
230
- continue
 
231
  words = section.split()
232
  if len(words) <= CHUNK_WORDS:
233
  chunks.append(section)
234
  continue
235
- step = CHUNK_WORDS - CHUNK_OVERLAP_WORDS
236
- for start in range(0, len(words), step):
237
- chunk = " ".join(words[start:start + CHUNK_WORDS]).strip()
238
- if chunk:
239
- chunks.append(chunk)
 
240
  if start + CHUNK_WORDS >= len(words):
241
  break
242
- return chunks
243
-
244
- async def hybrid_search(self, query: str, top_k: int = 5, alpha: float = 0.6) -> List[dict]:
245
- if not self._ready:
246
- logger.warning("hybrid_search called before ready β€” returning empty results")
247
- return []
248
- try:
249
- # fetch_k: retrieve 3x candidates, minimum floor of 15
250
- fetch_k = max(top_k * 3, 15)
251
-
252
- dense_results = await self.vectorstore.asimilarity_search_with_score(
253
- query, k=fetch_k
254
- )
255
- tokenized_query = self._tokens(query)
256
- bm25_scores = self.bm25.get_scores(tokenized_query)
257
- top_bm25_idx = np.argsort(bm25_scores)[::-1][:fetch_k].tolist()
258
-
259
- # Weighted RRF fusion
260
- # alpha controls dense list weight; (1-alpha) controls BM25 list weight
261
- K = 60
262
- rrf: dict[int, float] = {}
263
- for rank, (doc, _score) in enumerate(dense_results):
264
- idx = self._find_corpus_index(doc.page_content)
265
- if idx >= 0:
266
- rrf[idx] = rrf.get(idx, 0.0) + alpha * (1.0 / (K + rank + 1))
267
- for rank, idx in enumerate(top_bm25_idx):
268
- rrf[idx] = rrf.get(idx, 0.0) + (1 - alpha) * (1.0 / (K + rank + 1))
269
-
270
- sorted_idx = sorted(rrf, key=lambda i: rrf[i], reverse=True)[:top_k]
271
- results = []
272
- meaningful_query = {token for token in tokenized_query if token not in STOP_WORDS and len(token) > 2}
273
- for idx in sorted_idx:
274
- if idx < len(self.bm25_corpus):
275
- meta = self.bm25_meta[idx]
276
- chunk_tokens = set(self._tokens(self.bm25_corpus[idx]))
277
- lexical_overlap = len(meaningful_query & chunk_tokens)
278
- if meaningful_query and lexical_overlap == 0:
279
- continue
280
- results.append(
281
- {
282
- "content": self.bm25_corpus[idx],
283
- "source": meta.get("source", ""),
284
- "title": meta.get("title", ""),
285
- "filename": meta.get("filename", ""),
286
- "score": round(rrf[idx], 6),
287
- "lexical_overlap": lexical_overlap,
288
- "authority": meta.get("authority", "official"),
289
- "last_reviewed": meta.get("last_reviewed", ""),
290
- }
291
- )
292
- return results
293
- except Exception as exc:
294
- logger.error("hybrid_search error: %s", exc, exc_info=True)
295
- return []
296
-
297
- def _find_corpus_index(self, content: str) -> int:
298
- """O(1) reverse lookup using pre-built dict. Falls back to -1 if not found."""
299
- return self._content_to_idx.get(content.strip(), -1)
300
-
301
- def audit_knowledge_base(self) -> dict:
302
- """Scan KB files for duplicate titles. Call explicitly β€” not on every stats poll."""
303
- files = sorted(self._knowledge_dir().glob("*.txt"))
304
- titles: dict[str, str] = {}
305
- duplicates = []
306
- for path in files:
307
- title = path.stem
308
- try:
309
- text = path.read_text(encoding="utf-8", errors="ignore")[:500]
310
- first_title = next(
311
- (
312
- line.replace("TITLE:", "").strip()
313
- for line in text.splitlines()
314
- if line.startswith("TITLE:")
315
- ),
316
- title,
317
- )
318
- except Exception:
319
- first_title = title
320
- if first_title in titles:
321
- duplicates.append(
322
- {"title": first_title, "files": [titles[first_title], path.name]}
323
- )
324
- else:
325
- titles[first_title] = path.name
326
- return {"file_count": len(files), "duplicate_titles": duplicates[:20]}
327
-
328
- def get_stats(self) -> dict:
329
- """Lightweight stats β€” no disk reads beyond what's already in memory."""
330
- return {
331
- "ready": self._ready,
332
- "chunk_count": len(self.bm25_corpus),
333
- "knowledge_dir": str(self._knowledge_dir()),
334
- "vectorstore_loaded": self.vectorstore is not None,
335
- "bm25_loaded": self.bm25 is not None,
336
- "kb_hash": self._kb_hash,
337
- }
 
1
+ """Local keyword retrieval first, with an optional cached semantic index.
2
+
3
+ Initialization never needs an API key to serve the local KB. Dense failures
4
+ leave keyword search available; callers may run initialize in the background.
5
+ """
6
+ import asyncio
 
 
 
 
 
 
 
7
  import hashlib
8
  import hmac
9
+ import json
10
+ import logging
11
+ import os
12
+ import re
13
+ import time
14
+ from pathlib import Path
15
+ from typing import List
16
+
17
+ from src.config import get_settings
18
+
19
+ logger = logging.getLogger(__name__)
 
 
20
  CHUNK_WORDS = 260
21
  CHUNK_OVERLAP_WORDS = 40
22
+ EMBEDDING_MODEL = "text-embedding-3-small"
23
+ CACHE_SCHEMA = 2
24
+ TOKEN_RE = re.compile(r"[^\W_]+(?:[-_][^\W_]+)*", re.UNICODE)
25
  STOP_WORDS = {"about", "and", "are", "can", "for", "from", "how", "library", "please", "that", "the", "this", "what", "when", "where", "which", "with", "you"}
26
+ HEADER_RE = re.compile(r"^(?:SOURCE|TITLE|AUTHORITY|LAST_REVIEWED|LAST_UPDATED):", re.IGNORECASE)
27
+
28
+
29
+ class RAGService:
30
+ def __init__(self):
31
+ self.vectorstore = None
32
+ self.bm25 = None
33
+ self.bm25_corpus: List[str] = []
34
+ self.bm25_meta: List[dict] = []
35
+ self._embeddings = None
36
+ self._ready = False
37
+ self._kb_hash = ""
38
+ self._status = "not_started"
39
+ self._content_to_idx: dict[str, int] = {}
40
+ self._init_lock = asyncio.Lock()
41
+
42
+ def is_ready(self) -> bool:
43
+ return self._ready
44
+
45
+ def _knowledge_dir(self) -> Path:
46
+ return get_settings().kb_dir
47
+
48
+ def _cache_dir(self) -> Path:
49
+ return get_settings().rag_cache_dir
50
+
51
+ def _hash_knowledge_base(self, txt_files: List[Path]) -> str:
52
+ # Deployments commonly reset mtimes. Hash content plus indexing settings
53
+ # so unchanged data can reuse persisted embeddings across image rebuilds.
54
+ digest = hashlib.sha256()
55
+ digest.update(f"{CACHE_SCHEMA}:{EMBEDDING_MODEL}:{CHUNK_WORDS}:{CHUNK_OVERLAP_WORDS}".encode())
56
+ for path in sorted(txt_files):
57
+ digest.update(path.name.encode("utf-8"))
58
+ digest.update(b"\0")
59
+ digest.update(path.read_bytes())
60
+ digest.update(b"\0")
61
+ return digest.hexdigest()
62
+
63
  def _cache_paths(self) -> tuple[Path, Path, Path]:
64
+ cache_dir = self._cache_dir()
65
  return cache_dir / "faiss_index", cache_dir / "bm25_cache.json", cache_dir / "kb_state.json"
66
 
67
  def _cache_signature(self, faiss_dir: Path, bm25_path: Path) -> str:
 
77
 
78
  @staticmethod
79
  def _tokens(value: str) -> List[str]:
80
+ return TOKEN_RE.findall((value or "").casefold())
81
+
82
+ def _build_reverse_index(self) -> None:
83
+ self._content_to_idx = {chunk.strip(): i for i, chunk in enumerate(self.bm25_corpus)}
84
+
85
+ def _build_keyword_index(self):
86
+ from rank_bm25 import BM25Okapi
87
+ files = sorted(self._knowledge_dir().glob("*.txt"))
88
+ kb_hash = self._hash_knowledge_base(files)
89
+ chunks, metadata = self._load_and_chunk_all()
90
+ if not chunks:
91
+ raise ValueError("No usable knowledge-base chunks found")
92
+ bm25 = BM25Okapi([self._tokens(chunk) for chunk in chunks])
93
+ return kb_hash, chunks, metadata, bm25
94
+
95
+ async def initialize(self, openai_api_key: str = "", *, force_rebuild: bool = False) -> None:
96
+ async with self._init_lock:
97
+ started = time.monotonic()
98
+ self._status = "initializing"
99
+ try:
100
+ # File reads, tokenization, and heavy imports must not block the
101
+ # event loop serving /config, health checks, and chat requests.
102
+ kb_hash, chunks, metadata, bm25 = await asyncio.to_thread(self._build_keyword_index)
103
+ self.vectorstore = None
104
+ self._embeddings = None
105
+ self._kb_hash = kb_hash
106
+ self.bm25_corpus, self.bm25_meta, self.bm25 = chunks, metadata, bm25
107
+ self._build_reverse_index()
108
+ self._ready = True
109
+ self._status = "keyword_ready"
110
+ logger.info("RAG keyword index ready: %d chunks in %.2fs", len(chunks), time.monotonic() - started)
111
+ except Exception:
112
+ self._status = ("ready" if self.vectorstore is not None else "keyword_ready") if self._ready else "failed"
113
+ logger.exception("RAG keyword initialization failed; retaining any existing index")
114
+ return
115
+
116
+ if not openai_api_key:
117
+ return
118
+ self._status = "semantic_initializing"
119
+ try:
120
+ def load_embedding_tools():
121
+ from langchain_openai import OpenAIEmbeddings
122
+ from langchain_community.vectorstores import FAISS
123
+ return OpenAIEmbeddings, FAISS
124
+ OpenAIEmbeddings, FAISS = await asyncio.to_thread(load_embedding_tools)
125
+ self._embeddings = OpenAIEmbeddings(
126
+ model=EMBEDDING_MODEL, openai_api_key=openai_api_key,
127
+ request_timeout=15, max_retries=1,
128
+ )
129
+ if not force_rebuild and await asyncio.to_thread(self._try_load_cached_indexes, FAISS):
130
+ self._status = "ready"
131
+ return
132
+ vectorstore = await asyncio.wait_for(
133
+ FAISS.afrom_texts(chunks, self._embeddings, metadatas=metadata), timeout=90
134
+ )
135
+ self.vectorstore = vectorstore
136
+ self._status = "ready"
137
+ try:
138
+ await asyncio.to_thread(self._save_cached_indexes)
139
+ except Exception:
140
+ # An unwritable cache must not disable a working index.
141
+ logger.exception("RAG cache save failed; retrieval remains available")
142
+ logger.info("RAG semantic index ready in %.2fs", time.monotonic() - started)
143
+ except asyncio.CancelledError:
144
+ self._status = "keyword_ready"
145
+ raise
146
+ except Exception:
147
+ self._status = "keyword_ready"
148
+ logger.exception("RAG semantic index unavailable; using keyword retrieval")
149
+
150
+ def _try_load_cached_indexes(self, FAISS, _BM25Okapi=None) -> bool:
151
+ faiss_dir, bm25_path, state_path = self._cache_paths()
152
+ if not (faiss_dir.exists() and bm25_path.exists() and state_path.exists()):
153
+ return False
154
+ try:
155
+ state = json.loads(state_path.read_text(encoding="utf-8"))
156
+ if state.get("kb_hash") != self._kb_hash or state.get("schema") != CACHE_SCHEMA:
157
  return False
158
  expected = self._cache_signature(faiss_dir, bm25_path)
159
  if not expected or not hmac.compare_digest(str(state.get("signature", "")), expected):
160
+ logger.warning("RAG unsigned or modified cache ignored")
161
+ return False
162
+ cached = json.loads(bm25_path.read_text(encoding="utf-8"))
163
+ if cached.get("corpus") != self.bm25_corpus or cached.get("meta") != self.bm25_meta:
164
  return False
165
+ # FAISS uses pickle internally. Only load bytes authenticated with
166
+ # the origin secret; never load unsigned/user-supplied index files.
167
+ self.vectorstore = FAISS.load_local(
168
+ str(faiss_dir), self._embeddings, allow_dangerous_deserialization=True
169
+ )
170
+ return True
171
+ except Exception:
172
+ logger.warning("RAG cache could not be loaded; rebuilding", exc_info=True)
173
+ return False
174
+
175
+ def _save_cached_indexes(self) -> None:
176
+ if self.vectorstore is None or not get_settings().origin_token:
177
+ return
178
+ faiss_dir, bm25_path, state_path = self._cache_paths()
179
+ faiss_dir.mkdir(parents=True, exist_ok=True)
180
+ self.vectorstore.save_local(str(faiss_dir))
181
+ bm25_tmp = bm25_path.with_suffix(".tmp")
182
+ bm25_tmp.write_text(json.dumps({"corpus": self.bm25_corpus, "meta": self.bm25_meta}, ensure_ascii=False), encoding="utf-8")
183
+ os.replace(bm25_tmp, bm25_path)
184
+ state_tmp = state_path.with_suffix(".tmp")
185
+ state_tmp.write_text(json.dumps({
186
+ "schema": CACHE_SCHEMA, "kb_hash": self._kb_hash,
187
+ "embedding_model": EMBEDDING_MODEL, "chunk_count": len(self.bm25_corpus),
188
+ "signature": self._cache_signature(faiss_dir, bm25_path),
189
+ }), encoding="utf-8")
190
+ os.replace(state_tmp, state_path)
191
+
192
+ def _load_and_chunk_all(self) -> tuple[List[str], List[dict]]:
193
+ all_chunks, all_meta = [], []
194
+ seen: set[str] = set()
195
+ for path in sorted(self._knowledge_dir().glob("*.txt")):
196
+ try:
197
+ text = path.read_text(encoding="utf-8").strip()
198
+ if not text:
199
+ continue
200
+ header, header_lines = {}, set()
201
+ lines = text.splitlines()
202
+ for index, line in enumerate(lines[:8]):
203
+ if HEADER_RE.match(line.strip()):
204
+ key, value = line.split(":", 1)
205
+ header[key.strip().upper()] = value.strip()
206
+ header_lines.add(index)
207
+ body = "\n".join(line for index, line in enumerate(lines) if index not in header_lines)
208
+ metadata = {
209
+ "source": header.get("SOURCE", ""),
210
+ "title": header.get("TITLE", path.stem.replace("_", " ")),
211
+ "filename": path.name,
212
+ "authority": header.get("AUTHORITY", "supplied_library_content"),
213
+ "last_reviewed": header.get("LAST_REVIEWED", ""),
214
+ "last_updated": header.get("LAST_UPDATED", ""),
215
+ }
216
+ for chunk in self._chunk_text(body):
217
+ fingerprint = re.sub(r"\s+", " ", chunk).strip()
218
+ if fingerprint in seen:
219
+ continue
220
+ seen.add(fingerprint)
221
+ all_chunks.append(chunk)
222
+ all_meta.append(dict(metadata))
223
+ except Exception:
224
+ logger.warning("Could not read knowledge file %s", path.name, exc_info=True)
225
+ return all_chunks, all_meta
226
+
227
+ def _chunk_text(self, text: str) -> List[str]:
228
+ # Source files use RESOURCE 01:, all-caps headings without colons,
229
+ # staff names, and === section markers. Keep each entry with its URL.
230
+ sections, current = [], []
231
+ for line in text.splitlines():
232
+ stripped = line.strip()
233
+ is_heading = bool(
234
+ re.match(r"^(?:TOPIC:|RESOURCE\s+\d+:|===)", stripped)
235
+ or (4 <= len(stripped) <= 100 and stripped.isupper()
236
+ and any(char.isalpha() for char in stripped)
237
+ and not stripped.startswith(("http", "SOURCE:", "TITLE:")))
238
+ )
239
+ if is_heading and current:
240
+ sections.append("\n".join(current).strip())
241
+ current = []
242
+ current.append(line)
243
+ if current:
244
+ sections.append("\n".join(current).strip())
245
+ chunks = []
246
+ for section in sections:
247
+ if len(section) < 30 or not self._tokens(section):
248
+ continue
249
  words = section.split()
250
  if len(words) <= CHUNK_WORDS:
251
  chunks.append(section)
252
  continue
253
+ heading = section.splitlines()[0].strip()
254
+ for start in range(0, len(words), CHUNK_WORDS - CHUNK_OVERLAP_WORDS):
255
+ chunk = " ".join(words[start:start + CHUNK_WORDS])
256
+ if start and len(heading) < 100:
257
+ chunk = heading + "\n" + chunk
258
+ chunks.append(chunk)
259
  if start + CHUNK_WORDS >= len(words):
260
  break
261
+ return chunks
262
+
263
+ async def hybrid_search(self, query: str, top_k: int = 5, alpha: float = 0.6) -> List[dict]:
264
+ if not self._ready or self.bm25 is None or not query.strip() or top_k <= 0:
265
+ return []
266
+ # Snapshot before awaiting dense search so an admin rebuild cannot mix
267
+ # old vector document ids with a new corpus/metadata list.
268
+ corpus, metadata, bm25 = self.bm25_corpus, self.bm25_meta, self.bm25
269
+ vectorstore, reverse = self.vectorstore, self._content_to_idx
270
+ tokens = self._tokens(query)
271
+ meaningful = {token for token in tokens if token not in STOP_WORDS and len(token) > 1}
272
+ if not meaningful:
273
+ return []
274
+ fetch_k = min(max(top_k * 3, 15), len(corpus))
275
+ alpha = min(1.0, max(0.0, alpha))
276
+ scores = bm25.get_scores(tokens)
277
+ lexical_indices = sorted(range(len(corpus)), key=lambda idx: float(scores[idx]), reverse=True)
278
+ lexical_indices = [idx for idx in lexical_indices
279
+ if meaningful & set(self._tokens(corpus[idx]))][:fetch_k]
280
+ dense_results = []
281
+ if vectorstore is not None:
282
+ try:
283
+ dense_results = await asyncio.wait_for(
284
+ vectorstore.asimilarity_search_with_score(query, k=fetch_k), timeout=4
285
+ )
286
+ except Exception:
287
+ logger.warning("Semantic retrieval unavailable; using keywords", exc_info=True)
288
+ rrf: dict[int, float] = {}
289
+ for rank, (doc, _score) in enumerate(dense_results):
290
+ idx = reverse.get(doc.page_content.strip(), -1)
291
+ if idx >= 0:
292
+ rrf[idx] = rrf.get(idx, 0.0) + alpha / (61 + rank)
293
+ lexical_weight = (1 - alpha) if dense_results else 1.0
294
+ for rank, idx in enumerate(lexical_indices):
295
+ rrf[idx] = rrf.get(idx, 0.0) + lexical_weight / (61 + rank)
296
+ results = []
297
+ # Filter before applying top_k: irrelevant dense hits must not crowd
298
+ # relevant keyword hits out of the candidate pool.
299
+ for idx in sorted(rrf, key=rrf.get, reverse=True):
300
+ overlap = len(meaningful & set(self._tokens(corpus[idx])))
301
+ if not overlap:
302
+ continue
303
+ results.append({
304
+ "content": corpus[idx], **metadata[idx], "score": round(rrf[idx], 6),
305
+ "lexical_overlap": overlap,
306
+ "retrieval_mode": "hybrid" if dense_results else "keyword",
307
+ })
308
+ if len(results) >= top_k:
309
+ break
310
+ return results
311
+
312
+ def _find_corpus_index(self, content: str) -> int:
313
+ return self._content_to_idx.get(content.strip(), -1)
314
+
315
+ def audit_knowledge_base(self) -> dict:
316
+ files = sorted(self._knowledge_dir().glob("*.txt"))
317
+ titles, duplicates = {}, []
318
+ for path in files:
319
+ try:
320
+ text = path.read_text(encoding="utf-8")[:500]
321
+ title = next((line.split(":", 1)[1].strip() for line in text.splitlines() if line.startswith("TITLE:")), path.stem)
322
+ if title in titles:
323
+ duplicates.append({"title": title, "files": [titles[title], path.name]})
324
+ else:
325
+ titles[title] = path.name
326
+ except OSError:
327
+ continue
328
+ return {"file_count": len(files), "duplicate_titles": duplicates[:20]}
329
+
330
+ def get_stats(self) -> dict:
331
+ return {
332
+ "ready": self._ready, "status": self._status,
333
+ "retrieval_mode": "hybrid" if self.vectorstore is not None else "keyword" if self._ready else "unavailable",
334
+ "chunk_count": len(self.bm25_corpus), "knowledge_dir": str(self._knowledge_dir()),
335
+ "vectorstore_loaded": self.vectorstore is not None, "bm25_loaded": self.bm25 is not None,
336
+ "kb_hash": self._kb_hash,
337
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/services/runtime_store.py CHANGED
@@ -1,52 +1,57 @@
1
- """
2
- runtime_store.py β€” LibBee v3.1
3
-
4
- Fixes applied:
5
- 1. save() now uses atomic write-then-rename (os.replace) to prevent file
6
- corruption on crash or mid-write failure.
7
- os.replace is atomic on POSIX (Linux/macOS) and effectively atomic on
8
- Windows (same filesystem). This protects feedback.jsonl, metrics.json,
9
- and runtime_config.json from partial writes.
10
- """
11
- import json
12
- import os
13
- import time
14
- from pathlib import Path
15
- from typing import Any, Dict, Optional
16
-
17
-
18
- class JsonRuntimeStore:
19
- def __init__(self, path: Path, default: Optional[Dict[str, Any]] = None):
20
- self.path = Path(path)
21
- self.path.parent.mkdir(parents=True, exist_ok=True)
22
- self.default = default or {}
23
-
24
- def load(self) -> Dict[str, Any]:
25
- if not self.path.exists():
26
- return dict(self.default)
27
- try:
28
- return json.loads(self.path.read_text(encoding="utf-8"))
29
- except Exception:
30
- return dict(self.default)
31
-
32
- def save(self, data: Dict[str, Any]) -> None:
33
- """Atomically write data to disk using a temp file + os.replace."""
34
- tmp = self.path.with_suffix(".tmp")
35
- try:
36
- tmp.write_text(
37
- json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
38
- )
39
- os.replace(tmp, self.path)
40
- except Exception:
41
- try:
42
- tmp.unlink(missing_ok=True)
43
- except Exception:
44
- pass
45
- raise
46
-
47
- def update(self, **kwargs: Any) -> Dict[str, Any]:
48
- data = self.load()
49
- data.update(kwargs)
50
- data["updated_at"] = time.time()
51
- self.save(data)
52
- return data
 
 
 
 
 
 
1
+ """Small atomic JSON store for a single-process LibBee deployment."""
2
+
3
+ import copy
4
+ import json
5
+ import os
6
+ import tempfile
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Optional
10
+
11
+
12
+ class JsonRuntimeStore:
13
+ def __init__(self, path: Path, default: Optional[Dict[str, Any]] = None):
14
+ self.path = Path(path)
15
+ self.path.parent.mkdir(parents=True, exist_ok=True)
16
+ self.default = copy.deepcopy(default or {})
17
+
18
+ def load(self) -> Dict[str, Any]:
19
+ data = copy.deepcopy(self.default)
20
+ try:
21
+ loaded = json.loads(self.path.read_text(encoding="utf-8"))
22
+ if isinstance(loaded, dict):
23
+ data.update(loaded)
24
+ except (OSError, UnicodeError, json.JSONDecodeError):
25
+ pass
26
+ return data
27
+
28
+ def save(self, data: Dict[str, Any]) -> None:
29
+ """Replace a complete object with a unique, owner-only temporary file.
30
+
31
+ A shared fixed `.tmp` name allowed independent writers to overwrite or
32
+ unlink each other's temporary file. Unique files prevent that race;
33
+ callers still need a database for multi-process read/modify/write use.
34
+ """
35
+ if not isinstance(data, dict):
36
+ raise TypeError("runtime data must be a JSON object")
37
+ tmp = None
38
+ try:
39
+ with tempfile.NamedTemporaryFile(
40
+ mode="w", encoding="utf-8", dir=self.path.parent,
41
+ prefix=f".{self.path.name}.", suffix=".tmp", delete=False,
42
+ ) as handle:
43
+ tmp = Path(handle.name)
44
+ json.dump(data, handle, ensure_ascii=False, indent=2, allow_nan=False)
45
+ handle.flush()
46
+ os.fsync(handle.fileno())
47
+ os.replace(tmp, self.path)
48
+ finally:
49
+ if tmp is not None:
50
+ tmp.unlink(missing_ok=True)
51
+
52
+ def update(self, **kwargs: Any) -> Dict[str, Any]:
53
+ data = self.load()
54
+ data.update(kwargs)
55
+ data["updated_at"] = time.time()
56
+ self.save(data)
57
+ return data
src/services/staff_service.py CHANGED
@@ -1,222 +1,195 @@
1
- """
2
- staff_service.py β€” LibBee v3.1
3
-
4
- Fixes applied:
5
- 1. match_staff_name token logic corrected: previously checked if every question
6
- token was in the staff token set (inverted). Now checks if the staff member's
7
- own name tokens are all present in the question token set (correct direction).
8
- 2. Module-level build_staff_index() call removed β€” was running at import time,
9
- causing the index to be built twice (once at import, once in lifespan).
10
- app.py lifespan remains the single build trigger.
11
- 3. Fuzzy name matching added as a fallback in match_staff_name using
12
- difflib.get_close_matches (cutoff=0.75) to handle typos like "Nikkesh".
13
- 4. _ROLE_INDEX guard added: match_staff_role auto-builds if index is empty,
14
- defensive against out-of-order call scenarios.
15
- """
16
- import difflib
17
- import re
18
- from typing import Dict, List, Optional
19
-
20
- STAFF_DIRECTORY = [
21
- {
22
- "full_name": "Dr. Abdulla Al Hefeiti",
23
- "role": "Library Director / Assistant Provost, Libraries",
24
- "email": "abdulla.alhefeiti@ku.ac.ae",
25
- "phone": "+971 2 312 3331",
26
- "expertise": "strategic and institutional matters, library leadership, and partnerships",
27
- "tokens": ["abdulla", "hefeiti", "abdulla al hefeiti", "al hefeiti"],
28
- "aliases": ["library director", "director", "assistant provost"],
29
- },
30
- {
31
- "full_name": "Nikesh Narayanan",
32
- "role": "Research & Access Services Librarian",
33
- "email": "nikesh.narayanan@ku.ac.ae",
34
- "phone": "+971 2 312 3980",
35
- "expertise": "research support, Open Access publishing, Khazna repository, ORCID, Scopus, research impact, AI tools for research, bibliometrics, and scholarly communication",
36
- "tokens": ["nikesh", "narayanan", "nikesh narayanan"],
37
- "aliases": ["research librarian", "research support librarian"],
38
- },
39
- {
40
- "full_name": "Rani Anand",
41
- "role": "E-Resources Librarian",
42
- "email": "rani.anand@ku.ac.ae",
43
- "phone": "+971 2 312 3935",
44
- "expertise": "database access problems, e-resources troubleshooting, remote access, vendor issues, patents, e-books, and bibliometrics",
45
- "tokens": ["rani", "anand", "rani anand"],
46
- "aliases": ["e-resources librarian", "eresources librarian", "database librarian"],
47
- },
48
- {
49
- "full_name": "Jason Fetty",
50
- "role": "Medical Librarian",
51
- "email": "jason.fetty@ku.ac.ae",
52
- "phone": "+971 2 312 4722",
53
- "expertise": "medical and health sciences research, systematic reviews, PubMed, Embase, CINAHL, UpToDate, and clinical databases",
54
- "tokens": ["jason", "fetty", "jason fetty"],
55
- "aliases": ["medical librarian", "medical library", "health sciences librarian"],
56
- },
57
- {
58
- "full_name": "Walter Brian Hall",
59
- "role": "Digital & Technology Services Librarian / Systems Librarian",
60
- "email": "walter.hall@ku.ac.ae",
61
- "phone": "+971 2 312 3163",
62
- "expertise": "library website, systems, technology, digital infrastructure, ORCID, STEAM, coding, and Open Access support",
63
- "tokens": ["walter", "brian", "hall", "walter brian hall", "walter hall", "brian hall"],
64
- "aliases": ["systems librarian", "technology librarian", "digital librarian"],
65
- },
66
- {
67
- "full_name": "Alia Al-Harrasi",
68
- "role": "Manager, Technical Services",
69
- "email": "alia.alharrasi@ku.ac.ae",
70
- "phone": "+971 2 312 3180",
71
- "expertise": "cataloguing, metadata, acquisitions processing, and technical services",
72
- "tokens": ["alia", "alia al-harrasi", "alia alharrasi", "al harrasi", "alharrasi"],
73
- "aliases": ["technical services", "acquisitions processing", "cataloguing"],
74
- },
75
- ]
76
-
77
- _STOP_WORDS = {
78
- "who", "is", "are", "the", "a", "an", "can", "help", "me", "tell",
79
- "about", "find", "contact", "email", "phone", "number", "i", "need",
80
- "to", "speak", "with", "please", "get", "in", "touch", "reach", "how",
81
- "do", "what", "which", "where", "for", "of", "best", "person", "librarian",
82
- }
83
-
84
- _CONTACT_INTENT_RE = re.compile(
85
- r"\b(contact|email|phone|number|who is|who's|who handles|who can help|best person|best librarian|"
86
- r"which librarian|talk to|speak to|reach|appointment|book an appointment|schedule)",
87
- re.IGNORECASE,
88
- )
89
-
90
- _ROLE_INDEX: dict[str, Dict] = {}
91
-
92
-
93
- def build_staff_index() -> None:
94
- """Build role alias β†’ staff dict. Called once from app.py lifespan."""
95
- global _ROLE_INDEX
96
- _ROLE_INDEX = {}
97
- for staff in STAFF_DIRECTORY:
98
- for alias in [staff["full_name"], *staff.get("aliases", [])]:
99
- key = re.sub(r"[^a-z0-9]+", " ", alias.lower()).strip()
100
- _ROLE_INDEX[key] = staff
101
-
102
-
103
- def _normalize(text: str) -> List[str]:
104
- tokens = re.sub(r"[^a-z0-9 ]+", " ", (text or "").lower()).split()
105
- return [t for t in tokens if t not in _STOP_WORDS]
106
-
107
-
108
- def should_attempt_staff_lookup(question: str) -> bool:
109
- q = (question or "").strip().lower()
110
- if not q:
111
- return False
112
- if not _CONTACT_INTENT_RE.search(q):
113
- return False
114
- # Don't trigger on article/research queries that happen to contain contact words
115
- if re.search(
116
- r"\b(article|articles|paper|papers|study|studies|research article|research articles|literature)\b", q
117
- ) and not re.search(
118
- r"\b(who|contact|email|phone|which librarian|best person|who can help)\b", q
119
- ):
120
- return False
121
- return True
122
-
123
-
124
- def match_staff_name(question: str) -> Optional[Dict]:
125
- """
126
- Match by name tokens. ALL of a staff member's name tokens must appear
127
- in the question.
128
-
129
- Single-token matches are only accepted if the token is distinctive
130
- (β‰₯6 chars and not a common word). This prevents short or common tokens
131
- like 'hall', 'rani', 'brian', 'jason' from matching on unrelated queries.
132
- Multi-token matches (e.g. 'nikesh narayanan', 'rani anand') always accepted.
133
- """
134
- if not should_attempt_staff_lookup(question):
135
- return None
136
-
137
- question_token_set = set(_normalize(question))
138
- if not question_token_set:
139
- return None
140
-
141
- # Tokens that are too common/short to be used as sole match criteria
142
- _WEAK_TOKENS = {
143
- "hall", "rani", "brian", "alia", "jason", "walter",
144
- "anand", "fetty",
145
- }
146
-
147
- for staff in STAFF_DIRECTORY:
148
- staff_name_tokens: set[str] = set()
149
- for tok in staff.get("tokens", []):
150
- staff_name_tokens.update(_normalize(tok))
151
- if not staff_name_tokens:
152
- continue
153
- if not staff_name_tokens.issubset(question_token_set):
154
- continue
155
- # All tokens matched β€” now check if match is strong enough
156
- matched = staff_name_tokens & question_token_set
157
- if len(matched) >= 2:
158
- return staff
159
- # Single token match β€” only accept if distinctive
160
- sole = next(iter(matched))
161
- if len(sole) >= 6 and sole not in _WEAK_TOKENS:
162
- return staff
163
-
164
- # Fuzzy fallback for typos (e.g. "Nikkesh", "Al Harrasi")
165
- return _fuzzy_staff_match(question)
166
-
167
-
168
- def _fuzzy_staff_match(question: str) -> Optional[Dict]:
169
- """
170
- Fuzzy match individual question tokens against staff full names using difflib.
171
- Whole-question comparison is skipped β€” it never matches because a full sentence
172
- has near-zero similarity to a short name string.
173
- """
174
- all_names = [s["full_name"].lower() for s in STAFF_DIRECTORY]
175
- for token in _normalize(question):
176
- if len(token) < 4:
177
- continue
178
- token_matches = difflib.get_close_matches(
179
- token, all_names, n=1, cutoff=0.8
180
- )
181
- if token_matches:
182
- return next(
183
- (s for s in STAFF_DIRECTORY if s["full_name"].lower() == token_matches[0]),
184
- None,
185
- )
186
- return None
187
-
188
-
189
- def match_staff_role(question: str) -> Optional[Dict]:
190
- """Match by role alias. Auto-builds index if empty (defensive guard)."""
191
- if not should_attempt_staff_lookup(question):
192
- return None
193
- if not _ROLE_INDEX:
194
- build_staff_index()
195
- q = re.sub(r"[^a-z0-9]+", " ", question.lower()).strip()
196
- # Longest alias first β€” prevents "librarian" matching before "medical librarian"
197
- for alias, staff in sorted(_ROLE_INDEX.items(), key=lambda item: len(item[0]), reverse=True):
198
- if len(alias) > 3 and alias in q:
199
- return staff
200
- return None
201
-
202
-
203
- def staff_name_answer(staff: Dict) -> str:
204
- return (
205
- f"<strong>{staff['full_name']}</strong> is the <strong>{staff['role']}</strong>.<br><br>"
206
- f"They can help with: {staff['expertise']}.<br><br>"
207
- f"πŸ“§ <a href=\"mailto:{staff['email']}\">{staff['email']}</a><br>"
208
- f"πŸ“ž <a href=\"tel:{staff['phone'].replace(' ', '')}\">{staff['phone']}</a>"
209
- )
210
-
211
-
212
- def staff_role_answer(staff: Dict, question: str) -> str:
213
- return (
214
- f"For help with that, the best person to contact is <strong>{staff['full_name']}</strong> β€” "
215
- f"<strong>{staff['role']}</strong>.<br><br>"
216
- f"They can help with: {staff['expertise']}.<br><br>"
217
- f"πŸ“§ <a href=\"mailto:{staff['email']}\">{staff['email']}</a><br>"
218
- f"πŸ“ž <a href=\"tel:{staff['phone'].replace(' ', '')}\">{staff['phone']}</a>"
219
- )
220
-
221
- # NOTE: build_staff_index() is NOT called here at module level.
222
- # It is called once from app.py lifespan to avoid double-build.
 
1
+ """Deterministic staff matching using the supplied KU directory snapshot.
2
+
3
+ Live roles and contact details still require checking against the official
4
+ staff page; these helpers do not perform a live directory lookup.
5
+ """
6
+ import difflib
7
+ import re
8
+ from typing import Dict, List, Optional
9
+
10
+ STAFF_DIRECTORY = [
11
+ {
12
+ "full_name": "Dr. Abdulla Al Hefeiti",
13
+ "role": "Library Director / Assistant Provost, Libraries",
14
+ "email": "abdulla.alhefeiti@ku.ac.ae",
15
+ "phone": "+971 2 312 3331",
16
+ "expertise": "strategic and institutional matters, library leadership, and partnerships",
17
+ "tokens": ["abdulla", "hefeiti", "abdulla al hefeiti", "al hefeiti"],
18
+ "aliases": ["library director", "director", "assistant provost"],
19
+ },
20
+ {
21
+ "full_name": "Nikesh Narayanan",
22
+ "role": "Research & Access Services Librarian",
23
+ "email": "nikesh.narayanan@ku.ac.ae",
24
+ "phone": "+971 2 312 3980",
25
+ "expertise": "research support, Open Access publishing, Khazna repository, ORCID, Scopus, research impact, AI tools for research, bibliometrics, and scholarly communication",
26
+ "tokens": ["nikesh", "narayanan", "nikesh narayanan"],
27
+ "aliases": ["research librarian", "research support librarian"],
28
+ },
29
+ {
30
+ "full_name": "Rani Anand",
31
+ "role": "E-Resources Librarian",
32
+ "email": "rani.anand@ku.ac.ae",
33
+ "phone": "+971 2 312 3935",
34
+ "expertise": "database access problems, e-resources troubleshooting, remote access, vendor issues, patents, e-books, and bibliometrics",
35
+ "tokens": ["rani", "anand", "rani anand"],
36
+ "aliases": ["e-resources librarian", "eresources librarian", "database librarian"],
37
+ },
38
+ {
39
+ "full_name": "Jason Fetty",
40
+ "role": "Medical Librarian",
41
+ "email": "jason.fetty@ku.ac.ae",
42
+ "phone": "+971 2 312 4722",
43
+ "expertise": "medical and health sciences research, systematic reviews, PubMed, Embase, CINAHL, UpToDate, and clinical databases",
44
+ "tokens": ["jason", "fetty", "jason fetty"],
45
+ "aliases": ["medical librarian", "medical library", "health sciences librarian"],
46
+ },
47
+ {
48
+ "full_name": "Walter Brian Hall",
49
+ "role": "Digital & Technology Services Librarian / Systems Librarian",
50
+ "email": "walter.hall@ku.ac.ae",
51
+ "phone": "+971 2 312 3163",
52
+ "expertise": "library website, systems, technology, digital infrastructure, ORCID, STEAM, coding, and Open Access support",
53
+ "tokens": ["walter", "brian", "hall", "walter brian hall", "walter hall", "brian hall"],
54
+ "aliases": ["systems librarian", "technology librarian", "digital librarian"],
55
+ },
56
+ {
57
+ "full_name": "Alia Al-Harrasi",
58
+ "role": "Manager, Technical Services",
59
+ "email": "alia.alharrasi@ku.ac.ae",
60
+ "phone": "+971 2 312 3180",
61
+ "expertise": "cataloguing, metadata, acquisitions processing, and technical services",
62
+ "tokens": ["alia", "alia al-harrasi", "alia alharrasi", "al harrasi", "alharrasi"],
63
+ "aliases": ["technical services", "acquisitions processing", "cataloguing"],
64
+ },
65
+ ]
66
+
67
+ _STOP_WORDS = {
68
+ "who", "is", "are", "the", "a", "an", "can", "help", "me", "tell",
69
+ "about", "find", "contact", "email", "phone", "number", "i", "need",
70
+ "to", "speak", "with", "please", "get", "in", "touch", "reach", "how",
71
+ "do", "what", "which", "where", "for", "of", "best", "person", "librarian",
72
+ }
73
+
74
+ _CONTACT_INTENT_RE = re.compile(
75
+ r"\b(contact|email|phone|number|who is|who's|who handles|who can help|best person|best librarian|"
76
+ r"which librarian|talk to|speak to|reach|appointment|book an appointment|schedule)",
77
+ re.IGNORECASE,
78
+ )
79
+
80
+ _ROLE_INDEX: dict[str, Dict] = {}
81
+
82
+
83
+ def build_staff_index() -> None:
84
+ """Build role alias β†’ staff dict. Called once from app.py lifespan."""
85
+ global _ROLE_INDEX
86
+ _ROLE_INDEX = {}
87
+ for staff in STAFF_DIRECTORY:
88
+ for alias in [staff["full_name"], *staff.get("aliases", [])]:
89
+ key = re.sub(r"[^a-z0-9]+", " ", alias.lower()).strip()
90
+ _ROLE_INDEX[key] = staff
91
+
92
+
93
+ def _normalize(text: str) -> List[str]:
94
+ tokens = re.sub(r"[^a-z0-9 ]+", " ", (text or "").lower()).split()
95
+ return [t for t in tokens if t not in _STOP_WORDS]
96
+
97
+
98
+ def should_attempt_staff_lookup(question: str) -> bool:
99
+ q = (question or "").strip().lower()
100
+ if not q:
101
+ return False
102
+ if not _CONTACT_INTENT_RE.search(q):
103
+ return False
104
+ # Don't trigger on article/research queries that happen to contain contact words
105
+ if re.search(
106
+ r"\b(article|articles|paper|papers|study|studies|research article|research articles|literature)\b", q
107
+ ) and not re.search(
108
+ r"\b(who|contact|email|phone|which librarian|best person|who can help)\b", q
109
+ ):
110
+ return False
111
+ return True
112
+
113
+
114
+ def match_staff_name(question: str) -> Optional[Dict]:
115
+ """Match alternative spellings independently, without combining aliases.
116
+
117
+ A contact request can use a first name, surname or full-name alias. Common
118
+ words such as "hall" cannot identify a person on their own. Ambiguous
119
+ matches fall through to the KB instead of choosing the first staff entry.
120
+ """
121
+ if not should_attempt_staff_lookup(question):
122
+ return None
123
+ question_tokens = set(_normalize(question))
124
+ weak = {"al", "dr", "hall", "brian"}
125
+ candidates = []
126
+ for staff in STAFF_DIRECTORY:
127
+ strength = 0
128
+ for alias in [staff["full_name"], *staff.get("tokens", [])]:
129
+ tokens = set(_normalize(alias)) - {"dr"}
130
+ if tokens and tokens.issubset(question_tokens):
131
+ if len(tokens) >= 2 or next(iter(tokens)) not in weak:
132
+ strength = max(strength, len(tokens))
133
+ if strength:
134
+ candidates.append(staff)
135
+ if len(candidates) == 1:
136
+ return candidates[0]
137
+ if candidates:
138
+ return None
139
+ return _fuzzy_staff_match(question)
140
+
141
+
142
+ def _fuzzy_staff_match(question: str) -> Optional[Dict]:
143
+ """Compare probable name words with name words, not full sentences."""
144
+ candidates = {}
145
+ for token in _normalize(question):
146
+ if len(token) < 6:
147
+ continue
148
+ for staff in STAFF_DIRECTORY:
149
+ distinctive = {
150
+ word for alias in staff.get("tokens", []) for word in _normalize(alias)
151
+ if len(word) >= 6 and word not in {"librarian", "library"}
152
+ }
153
+ if difflib.get_close_matches(token, distinctive, n=1, cutoff=0.88):
154
+ candidates[staff["email"]] = staff
155
+ return next(iter(candidates.values())) if len(candidates) == 1 else None
156
+
157
+
158
+ def match_staff_role(question: str) -> Optional[Dict]:
159
+ """Match by role alias. Auto-builds index if empty (defensive guard)."""
160
+ if not should_attempt_staff_lookup(question):
161
+ return None
162
+ if not _ROLE_INDEX:
163
+ build_staff_index()
164
+ q = re.sub(r"[^a-z0-9]+", " ", question.lower()).strip()
165
+ # Longest alias first β€” prevents "librarian" matching before "medical librarian"
166
+ for alias, staff in sorted(_ROLE_INDEX.items(), key=lambda item: len(item[0]), reverse=True):
167
+ if len(alias) > 3 and re.search(r"(?<!\w)" + re.escape(alias) + r"(?!\w)", q):
168
+ return staff
169
+ return None
170
+
171
+
172
+ def staff_name_answer(staff: Dict) -> str:
173
+ return (
174
+ f"<strong>{staff['full_name']}</strong> is the <strong>{staff['role']}</strong>.<br><br>"
175
+ f"They can help with: {staff['expertise']}.<br><br>"
176
+ f"πŸ“§ <a href=\"mailto:{staff['email']}\">{staff['email']}</a><br>"
177
+ f"πŸ“ž <a href=\"tel:{staff['phone'].replace(' ', '')}\">{staff['phone']}</a>"
178
+ '<br><br>Contact details are from the supplied directory. '
179
+ '<a href="https://library.ku.ac.ae/librarystaff" target="_blank">Check the current staff directory</a>.'
180
+ )
181
+
182
+
183
+ def staff_role_answer(staff: Dict, question: str) -> str:
184
+ return (
185
+ f"For help with that, the best person to contact is <strong>{staff['full_name']}</strong> β€” "
186
+ f"<strong>{staff['role']}</strong>.<br><br>"
187
+ f"They can help with: {staff['expertise']}.<br><br>"
188
+ f"πŸ“§ <a href=\"mailto:{staff['email']}\">{staff['email']}</a><br>"
189
+ f"πŸ“ž <a href=\"tel:{staff['phone'].replace(' ', '')}\">{staff['phone']}</a>"
190
+ '<br><br>Contact details are from the supplied directory. '
191
+ '<a href="https://library.ku.ac.ae/librarystaff" target="_blank">Check the current staff directory</a>.'
192
+ )
193
+
194
+ # NOTE: build_staff_index() is NOT called here at module level.
195
+ # It is called once from app.py lifespan to avoid double-build.