File size: 5,991 Bytes
4a4df15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

import hashlib
import re
import secrets
from typing import Callable
import numpy as np
from .schemas import Chunk, ParsedCorpus, Topic
from .workers import CPUWorker


class Embedder:
    def __init__(self, worker: CPUWorker, demo: bool = False):
        self.worker, self.demo = worker, demo

    def encode(self, texts: list[str], progress: Callable[[str], None] | None = None) -> np.ndarray:
        if not texts:
            return np.zeros((0, 0), dtype="float32")
        if self.demo:
            # Explicit demo mode ONLY. Never used as a silent semantic fallback.
            out = np.zeros((len(texts), 256), dtype="float32")
            for i, text in enumerate(texts):
                for token in re.findall(r"\w+", text.casefold()):
                    out[i, int.from_bytes(hashlib.sha256(token.encode()).digest()[:4], "big") % 256] += 1
            return out / np.maximum(np.linalg.norm(out, axis=1, keepdims=True), 1e-9)
        vectors = []
        for start in range(0, len(texts), 16):
            if progress:
                progress(f"Embedding passages {start+1}-{min(start+16, len(texts))} of {len(texts)} on CPU...")
            result = self.worker.request("embed", {"texts": texts[start:start+16]}, timeout=480, progress=progress)
            vectors.extend(result["vectors"])
        out = np.asarray(vectors, dtype="float32")
        if out.ndim != 2 or len(out) != len(texts) or not np.isfinite(out).all():
            raise RuntimeError("Embedding model returned invalid vectors.")
        return out / np.maximum(np.linalg.norm(out, axis=1, keepdims=True), 1e-9)


class CorpusIndex:
    """Per-session vectors; FAISS when installed, mathematically equivalent NumPy otherwise."""
    def __init__(self, parsed: ParsedCorpus, vectors: np.ndarray):
        if len(vectors) != len(parsed.chunks):
            raise ValueError("Chunk/vector count mismatch.")
        self.parsed = parsed
        self.chunks = parsed.chunks
        self.by_id = {c.id: c for c in self.chunks}
        self.topics = {t.id: t for t in parsed.topics}
        self.vectors = np.ascontiguousarray(vectors, dtype="float32")
        self.faiss = None
        try:
            import faiss
            self.faiss = faiss.IndexFlatIP(self.vectors.shape[1])
            self.faiss.add(self.vectors)
        except ImportError:
            # The vectors are still real semantic embeddings; only the exact
            # similarity-search implementation differs for small corpora.
            pass

    def options(self) -> list[tuple[str, str]]:
        return [("Any primary topic (random selection)", "all")] + [(t.label, t.id) for t in self.parsed.topics]

    def hits(self, vector: np.ndarray, *, role: str, ids: set[str] | None = None,
             query: str = "", top_k: int = 6) -> list[Chunk]:
        vector = np.asarray(vector, dtype="float32").reshape(-1)
        if vector.shape[0] != self.vectors.shape[1]:
            raise ValueError("Embedding dimension mismatch. Rebuild the corpus.")
        # Filter scope BEFORE ranking; a globally similar chapter cannot starve
        # a selected narrow topic of results.
        candidates = [i for i, c in enumerate(self.chunks) if c.role == role and (ids is None or c.id in ids)]
        if not candidates:
            return []
        if self.faiss is not None:
            scores, rows = self.faiss.search(vector.reshape(1, -1), len(self.chunks))
            score_map = dict(zip(map(int, rows[0]), map(float, scores[0])))
            dense = np.asarray([score_map[i] for i in candidates])
        else:
            dense = self.vectors[candidates] @ vector
        dense_order = np.argsort(-dense)
        tokens = set(re.findall(r"\w+", query.casefold()))
        lexical = [len(tokens & set(re.findall(r"\w+", self.chunks[i].text.casefold()))) for i in candidates]
        lex_order = np.argsort(-np.asarray(lexical))
        fused = np.zeros(len(candidates))
        for rank, pos in enumerate(dense_order):
            fused[pos] += 1 / (60 + rank)
        if any(lexical):
            for rank, pos in enumerate(lex_order):
                fused[pos] += 0.35 / (60 + rank)
        return [self.chunks[candidates[int(p)]] for p in np.argsort(-fused)[:top_k]]

    def question_context(self, topic_id: str, focus: str, embedder: Embedder) -> tuple[Topic, list[Chunk]]:
        if topic_id == "all":
            # Select a coherent topic, rather than the first N pages of the corpus.
            subtopics = [t for t in self.parsed.topics if "-T" in t.id]
            topic = secrets.choice(subtopics or self.parsed.topics)
        else:
            if topic_id not in self.topics:
                raise ValueError("Choose a topic from the current corpus.")
            topic = self.topics[topic_id]
        candidates = [self.by_id[sid] for sid in topic.source_ids]
        if focus.strip():
            vector = embedder.encode([focus])[0]
            selected = self.hits(vector, role="primary", ids=set(topic.source_ids), query=focus, top_k=7)
            # Neighbour passages remain within the selected topic.
            nearby = {c.ordinal + d for c in selected for d in (-1, 0, 1)}
            candidates = [c for c in candidates if c.ordinal in nearby][:12]
        elif len(candidates) > 10:
            start = secrets.randbelow(len(candidates) - 7)
            candidates = candidates[start:start+8]
        return topic, candidates


def pack_context(chunks: list[Chunk], max_chars: int) -> tuple[str, list[Chunk]]:
    blocks, admitted, length = [], [], 0
    seen: set[str] = set()
    for c in chunks:
        if c.id in seen:
            continue
        block = f"[SOURCE {c.id}; {c.role}; {c.filename}; {c.location}; section: {c.heading}]\n{c.text}"
        if length + len(block) > max_chars:
            continue
        blocks.append(block)
        admitted.append(c)
        seen.add(c.id)
        length += len(block) + 2
    return "\n\n".join(blocks), admitted