nikeshn commited on
Commit
60fc368
·
verified ·
1 Parent(s): 038ad63

Upload 7 files

Browse files
src/services/html_sanitizer.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small allow-list sanitizer for answer fragments returned to the browser."""
2
+
3
+ from html import escape
4
+ from html.parser import HTMLParser
5
+ from urllib.parse import urlparse
6
+
7
+
8
+ 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"}
9
+ VOID_TAGS = {"br", "hr"}
10
+ DROP_CONTENT_TAGS = {"button", "form", "iframe", "input", "math", "object", "script", "style", "svg"}
11
+ GLOBAL_ATTRIBUTES = {"aria-controls", "aria-expanded", "aria-label", "class", "role"}
12
+ TAG_ATTRIBUTES = {"a": {"href", "rel", "target", "title"}, "td": {"colspan", "rowspan"}, "th": {"colspan", "rowspan", "scope"}}
13
+
14
+
15
+ def _safe_href(value: str) -> str | None:
16
+ candidate = value.strip()
17
+ if not candidate:
18
+ return None
19
+ parsed = urlparse(candidate)
20
+ if parsed.scheme.lower() not in {"https", "mailto", "tel"}:
21
+ return None
22
+ return candidate
23
+
24
+
25
+ class _Sanitizer(HTMLParser):
26
+ def __init__(self) -> None:
27
+ super().__init__(convert_charrefs=True)
28
+ self.output: list[str] = []
29
+ self.drop_depth = 0
30
+
31
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
32
+ tag = tag.lower()
33
+ if tag in DROP_CONTENT_TAGS:
34
+ self.drop_depth += 1
35
+ return
36
+ if self.drop_depth or tag not in ALLOWED_TAGS:
37
+ return
38
+ rendered: list[str] = []
39
+ for raw_name, raw_value in attrs:
40
+ name = raw_name.lower()
41
+ if name.startswith("on") or name == "style":
42
+ continue
43
+ if name not in GLOBAL_ATTRIBUTES and name not in TAG_ATTRIBUTES.get(tag, set()):
44
+ continue
45
+ value = raw_value or ""
46
+ if tag == "a" and name == "href":
47
+ value = _safe_href(value) or ""
48
+ if not value:
49
+ continue
50
+ if tag == "a" and name == "target" and value != "_blank":
51
+ continue
52
+ rendered.append(f' {name}="{escape(value, quote=True)}"')
53
+ if tag == "a":
54
+ rendered = [item for item in rendered if not item.startswith(" rel=")]
55
+ rendered.append(' rel="noopener noreferrer"')
56
+ self.output.append(f"<{tag}{''.join(rendered)}>")
57
+
58
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
59
+ self.handle_starttag(tag, attrs)
60
+
61
+ def handle_endtag(self, tag: str) -> None:
62
+ tag = tag.lower()
63
+ if tag in DROP_CONTENT_TAGS:
64
+ if self.drop_depth:
65
+ self.drop_depth -= 1
66
+ return
67
+ if not self.drop_depth and tag in ALLOWED_TAGS and tag not in VOID_TAGS:
68
+ self.output.append(f"</{tag}>")
69
+
70
+ def handle_data(self, data: str) -> None:
71
+ if not self.drop_depth:
72
+ self.output.append(escape(data, quote=False))
73
+
74
+
75
+ def sanitize_answer_html(value: str) -> str:
76
+ parser = _Sanitizer()
77
+ parser.feed(value or "")
78
+ parser.close()
79
+ return "".join(parser.output)
src/services/http_client.py CHANGED
@@ -1,44 +1,44 @@
1
- """Shared outbound HTTP client factory.
2
-
3
- Deliberately plain: the default dual-stack resolver and a moderate timeout.
4
-
5
- History: v3.8.2 pinned outbound sockets to IPv4 (local_address="0.0.0.0") to
6
- chase a suspected IPv6 routing problem. Analytics writes had been succeeding
7
- before that change and stopped after it, so the pin is reverted. Forcing a
8
- local address can break egress in containerised hosts, and the original
9
- diagnosis was not supported by evidence: a 12s GET failed while an 8s POST to
10
- the same host succeeded, which rules out plain slowness.
11
-
12
- Set FORCE_IPV4_LOCAL_ADDRESS to "0.0.0.0" only to test that hypothesis again.
13
- """
14
- import logging
15
- from typing import Optional
16
-
17
- import httpx
18
-
19
- logger = logging.getLogger(__name__)
20
-
21
- # None = let the OS choose (normal dual-stack behaviour). "0.0.0.0" forces IPv4.
22
- FORCE_IPV4_LOCAL_ADDRESS: Optional[str] = None
23
-
24
- DEFAULT_TIMEOUT = 10.0
25
-
26
-
27
- def make_transport(retries: int = 1) -> httpx.AsyncHTTPTransport:
28
- kwargs = {"retries": retries}
29
- if FORCE_IPV4_LOCAL_ADDRESS:
30
- kwargs["local_address"] = FORCE_IPV4_LOCAL_ADDRESS
31
- try:
32
- return httpx.AsyncHTTPTransport(**kwargs)
33
- except Exception as exc:
34
- logger.warning(f"Custom transport unavailable ({exc}); using default")
35
- return httpx.AsyncHTTPTransport()
36
-
37
-
38
- def make_client(timeout: float = DEFAULT_TIMEOUT, retries: int = 1) -> httpx.AsyncClient:
39
- """AsyncClient with a moderate timeout and one transport-level retry."""
40
- return httpx.AsyncClient(
41
- timeout=timeout,
42
- transport=make_transport(retries=retries),
43
- follow_redirects=True,
44
- )
 
1
+ """Shared outbound HTTP client factory.
2
+
3
+ Deliberately plain: the default dual-stack resolver and a moderate timeout.
4
+
5
+ History: v3.8.2 pinned outbound sockets to IPv4 (local_address="0.0.0.0") to
6
+ chase a suspected IPv6 routing problem. Analytics writes had been succeeding
7
+ before that change and stopped after it, so the pin is reverted. Forcing a
8
+ local address can break egress in containerised hosts, and the original
9
+ diagnosis was not supported by evidence: a 12s GET failed while an 8s POST to
10
+ the same host succeeded, which rules out plain slowness.
11
+
12
+ Set FORCE_IPV4_LOCAL_ADDRESS to "0.0.0.0" only to test that hypothesis again.
13
+ """
14
+ import logging
15
+ from typing import Optional
16
+
17
+ import httpx
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # None = let the OS choose (normal dual-stack behaviour). "0.0.0.0" forces IPv4.
22
+ FORCE_IPV4_LOCAL_ADDRESS: Optional[str] = None
23
+
24
+ DEFAULT_TIMEOUT = 10.0
25
+
26
+
27
+ def make_transport(retries: int = 1) -> httpx.AsyncHTTPTransport:
28
+ kwargs = {"retries": retries}
29
+ if FORCE_IPV4_LOCAL_ADDRESS:
30
+ kwargs["local_address"] = FORCE_IPV4_LOCAL_ADDRESS
31
+ try:
32
+ return httpx.AsyncHTTPTransport(**kwargs)
33
+ except Exception as exc:
34
+ logger.warning(f"Custom transport unavailable ({exc}); using default")
35
+ return httpx.AsyncHTTPTransport()
36
+
37
+
38
+ def make_client(timeout: float = DEFAULT_TIMEOUT, retries: int = 1) -> httpx.AsyncClient:
39
+ """AsyncClient with a moderate timeout and one transport-level retry."""
40
+ return httpx.AsyncClient(
41
+ timeout=timeout,
42
+ transport=make_transport(retries=retries),
43
+ follow_redirects=True,
44
+ )
src/services/rag_service.py CHANGED
@@ -11,7 +11,8 @@ Fixes applied:
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 json
16
  import logging
17
  import os
@@ -25,8 +26,10 @@ import numpy as np
25
  from src.config import get_settings
26
 
27
  logger = logging.getLogger(__name__)
28
- CHUNK_MAX_CHARS = 1600
29
- CHUNK_OVERLAP_CHARS = 200
 
 
30
 
31
 
32
  class RAGService:
@@ -59,9 +62,24 @@ class RAGService:
59
  digest.update(str(stat.st_size).encode("utf-8"))
60
  return digest.hexdigest()
61
 
62
- def _cache_paths(self) -> tuple[Path, Path, Path]:
63
  cache_dir = self._cache_dir()
64
- return cache_dir / "faiss_index", cache_dir / "bm25_cache.json", cache_dir / "kb_state.json"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
  def _build_reverse_index(self) -> None:
67
  """Build O(1) content -> index lookup. Call after any corpus change."""
@@ -103,7 +121,7 @@ class RAGService:
103
  )
104
  self.bm25_corpus = chunks
105
  self.bm25_meta = metadatas
106
- tokenized = [c.lower().split() for c in chunks]
107
  self.bm25 = BM25Okapi(tokenized)
108
  self._build_reverse_index()
109
  self._save_cached_indexes()
@@ -120,16 +138,20 @@ class RAGService:
120
  return False
121
  try:
122
  state = json.loads(state_path.read_text(encoding="utf-8"))
123
- if state.get("kb_hash") != self._kb_hash:
124
  logger.info("RAGService: KB changed (hash mismatch) — rebuilding indexes")
125
- return False
 
 
 
 
126
  self.vectorstore = FAISS.load_local(
127
  str(faiss_dir), self._embeddings, allow_dangerous_deserialization=True
128
  )
129
  bm25_cache = json.loads(bm25_path.read_text(encoding="utf-8"))
130
  self.bm25_corpus = bm25_cache["corpus"]
131
  self.bm25_meta = bm25_cache["meta"]
132
- tokenized = [c.lower().split() for c in self.bm25_corpus]
133
  self.bm25 = BM25Okapi(tokenized)
134
  return True
135
  except Exception as exc:
@@ -155,8 +177,8 @@ class RAGService:
155
 
156
  # Atomic write for state
157
  state_tmp = state_path.with_suffix(".tmp")
158
- state_tmp.write_text(
159
- json.dumps({"kb_hash": self._kb_hash, "chunk_count": len(self.bm25_corpus)}),
160
  encoding="utf-8",
161
  )
162
  os.replace(state_tmp, state_path)
@@ -174,18 +196,25 @@ class RAGService:
174
  text = fpath.read_text(encoding="utf-8").strip()
175
  if not text:
176
  continue
177
- source_url = ""
178
- title = fpath.stem.replace("_", " ").replace("-", " ")
179
- for line in text.splitlines()[:8]:
 
 
180
  line = line.strip()
181
  if line.startswith("SOURCE:"):
182
  source_url = line.replace("SOURCE:", "").strip()
183
- elif line.startswith("TITLE:"):
184
- title = line.replace("TITLE:", "").strip()
 
 
 
 
185
  for chunk in self._chunk_text(text):
186
  all_chunks.append(chunk)
187
  all_meta.append(
188
- {"source": source_url, "title": title, "filename": fpath.name}
 
189
  )
190
  except Exception as exc:
191
  logger.warning("Failed to load %s: %s", fpath.name, exc)
@@ -199,18 +228,17 @@ class RAGService:
199
  section = section.strip()
200
  if not section or len(section) < 30:
201
  continue
202
- if len(section) <= CHUNK_MAX_CHARS:
203
- chunks.append(section)
204
- else:
205
- start = 0
206
- while start < len(section):
207
- end = start + CHUNK_MAX_CHARS
208
- chunk = section[start:end].strip()
209
- if chunk:
210
- chunks.append(chunk)
211
- if end >= len(section):
212
- break
213
- start += CHUNK_MAX_CHARS - CHUNK_OVERLAP_CHARS
214
  return chunks
215
 
216
  async def hybrid_search(self, query: str, top_k: int = 5, alpha: float = 0.6) -> List[dict]:
@@ -224,7 +252,7 @@ class RAGService:
224
  dense_results = await self.vectorstore.asimilarity_search_with_score(
225
  query, k=fetch_k
226
  )
227
- tokenized_query = query.lower().split()
228
  bm25_scores = self.bm25.get_scores(tokenized_query)
229
  top_bm25_idx = np.argsort(bm25_scores)[::-1][:fetch_k].tolist()
230
 
@@ -240,17 +268,25 @@ class RAGService:
240
  rrf[idx] = rrf.get(idx, 0.0) + (1 - alpha) * (1.0 / (K + rank + 1))
241
 
242
  sorted_idx = sorted(rrf, key=lambda i: rrf[i], reverse=True)[:top_k]
243
- results = []
244
- for idx in sorted_idx:
245
- if idx < len(self.bm25_corpus):
246
- meta = self.bm25_meta[idx]
247
- results.append(
 
 
 
 
 
248
  {
249
  "content": self.bm25_corpus[idx],
250
  "source": meta.get("source", ""),
251
  "title": meta.get("title", ""),
252
  "filename": meta.get("filename", ""),
253
- "score": round(rrf[idx], 6),
 
 
 
254
  }
255
  )
256
  return results
 
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
 
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:
 
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:
70
+ key = get_settings().origin_token.encode("utf-8")
71
+ if not key:
72
+ return ""
73
+ digest = hmac.new(key, digestmod=hashlib.sha256)
74
+ for path in sorted([bm25_path, *faiss_dir.glob("*")]):
75
+ if path.is_file():
76
+ digest.update(path.name.encode("utf-8"))
77
+ digest.update(path.read_bytes())
78
+ return digest.hexdigest()
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."""
 
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()
 
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:
 
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)
 
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)
 
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]:
 
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
 
 
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