Spaces:
Sleeping
Sleeping
File size: 7,156 Bytes
3ad3d7f 7828754 3ad3d7f 7828754 3ad3d7f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | """
Risk knowledge base β FAISS index + RAG retrieval (Tasks 2.6.4, 2.6.5).
Builds a FAISS index over the markdown reference docs in ``knowledge_base/``
(severity rubric, NIST CSF summary, US + international regulatory references), chunked to ~200 words
and embedded with ``all-MiniLM-L6-v2``. ``retrieve_severity_context`` queries the
index with a signal and returns the top-k most relevant chunks so the Risk
Analysis Agent (Task 2.7) can ground its severity judgments.
The embedder is injectable (shared with ``deduplication``) so tests can run with
a deterministic fake and no model download.
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Optional, Sequence
import numpy as np
from src.analysis.deduplication import Embedder, get_default_embedder
logger = logging.getLogger(__name__)
# knowledge_base/ sits at the project root (two levels up from this file: src/analysis/).
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
KB_DIR = _PROJECT_ROOT / "knowledge_base"
KB_FILES = (
"severity_rubric.md",
"nist_csf_summary.md",
"regulatory_reference.md",
"regulatory_reference_intl.md",
)
INDEX_PATH = KB_DIR / "severity.index"
CHUNKS_PATH = KB_DIR / "severity_chunks.json"
CHUNK_TARGET_WORDS = 200
# In-memory cache keyed by index path so repeated retrievals (and tests using
# different paths) don't reload or collide.
_cache: dict[str, tuple] = {}
# ββ Chunking ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def chunk_markdown(text: str, *, source: str = "", target_words: int = CHUNK_TARGET_WORDS) -> list[str]:
"""Split markdown into ~target_words chunks, keeping the nearest heading as a
prefix so each chunk carries its context (e.g. 'CYBERSECURITY').
Words from each heading section accumulate and are emitted in fixed
target_words windows, so even a single very long paragraph is split.
"""
heading = ""
chunks: list[str] = []
words: list[str] = []
def flush() -> None:
nonlocal words
prefix = f"[{source}] " if source else ""
ctx = f"{heading}: " if heading else ""
for i in range(0, len(words), target_words):
body = " ".join(words[i : i + target_words]).strip()
if body:
chunks.append(f"{prefix}{ctx}{body}")
words = []
for raw in text.splitlines():
line = raw.strip()
m = re.match(r"^(#{1,6})\s+(.*)$", line)
if m:
flush()
heading = m.group(2).strip()
continue
if not line or re.fullmatch(r"-{3,}", line): # blank line or horizontal rule
continue
words.extend(line.split())
flush()
return chunks
def load_kb_chunks(doc_paths: Optional[Sequence[Path]] = None) -> list[str]:
"""Read and chunk all knowledge-base documents."""
paths = [Path(p) for p in (doc_paths or [KB_DIR / f for f in KB_FILES])]
chunks: list[str] = []
for path in paths:
if not path.exists():
logger.warning("knowledge-base doc missing: %s", path)
continue
text = path.read_text(encoding="utf-8")
chunks.extend(chunk_markdown(text, source=path.stem))
return chunks
# ββ Index build / load ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def build_index(
*,
embedder: Optional[Embedder] = None,
doc_paths: Optional[Sequence[Path]] = None,
index_path: Path = INDEX_PATH,
chunks_path: Path = CHUNKS_PATH,
) -> int:
"""Build the FAISS index over the knowledge base and write it to disk.
Returns the number of chunks indexed. Uses inner-product over L2-normalised
embeddings, i.e. cosine similarity.
"""
import faiss # deferred β heavy native dependency
embedder = embedder or get_default_embedder()
chunks = load_kb_chunks(doc_paths)
if not chunks:
raise RuntimeError(f"No knowledge-base chunks found (looked in {KB_DIR}).")
emb = np.asarray(embedder(chunks), dtype="float32")
faiss.normalize_L2(emb)
index = faiss.IndexFlatIP(emb.shape[1])
index.add(emb)
index_path.parent.mkdir(parents=True, exist_ok=True)
faiss.write_index(index, str(index_path))
chunks_path.write_text(json.dumps(chunks, ensure_ascii=False, indent=2), encoding="utf-8")
_cache.pop(str(index_path), None)
logger.info("Built knowledge-base index: %d chunks -> %s", len(chunks), index_path)
return len(chunks)
def _load_index(index_path: Path, chunks_path: Path):
key = str(index_path)
if key in _cache:
return _cache[key]
import faiss
if not index_path.exists() or not chunks_path.exists():
logger.info("Knowledge-base index missing β building it now.")
build_index(index_path=index_path, chunks_path=chunks_path)
index = faiss.read_index(str(index_path))
chunks = json.loads(chunks_path.read_text(encoding="utf-8"))
_cache[key] = (index, chunks)
return index, chunks
# ββ Retrieval (Task 2.6.5) ββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _signal_query(signal) -> str:
"""Build a retrieval query from a RiskSignal (category + subcategory + text)."""
cat = getattr(getattr(signal, "risk_category", None), "value", "") or ""
sub = getattr(signal, "risk_subcategory", "") or ""
text = getattr(signal, "text", "") or ""
return " ".join(p for p in (cat, sub, text) if p).strip()
async def retrieve_severity_context(
signal,
k: int = 3,
*,
embedder: Optional[Embedder] = None,
index_path: Path = INDEX_PATH,
chunks_path: Path = CHUNKS_PATH,
) -> list[str]:
"""Return the top-k knowledge-base chunks most relevant to *signal*.
*signal* is a ``RiskSignal`` (or anything with risk_category / risk_subcategory
/ text). The index is built lazily on first use if absent.
"""
import asyncio
import faiss
query = _signal_query(signal)
if not query:
return []
def _search() -> list[str]:
index, chunks = _load_index(index_path, chunks_path)
emb_fn = embedder or get_default_embedder()
q = np.asarray(emb_fn([query]), dtype="float32")
faiss.normalize_L2(q)
_scores, idxs = index.search(q, min(k, len(chunks)))
return [chunks[i] for i in idxs[0] if i != -1]
return await asyncio.to_thread(_search)
__all__ = [
"KB_DIR",
"KB_FILES",
"INDEX_PATH",
"CHUNKS_PATH",
"chunk_markdown",
"load_kb_chunks",
"build_index",
"retrieve_severity_context",
]
if __name__ == "__main__": # `python -m src.analysis.knowledge_base` builds the index
logging.basicConfig(level=logging.INFO)
n = build_index()
print(f"Indexed {n} knowledge-base chunks at {INDEX_PATH}")
|