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}")