File size: 14,535 Bytes
2e818da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
import hashlib
from pathlib import Path
from typing import Any, Dict, List, Optional

import chromadb
from chromadb.errors import NotFoundError

from app.observability import retrieval as _obs_retrieval
from app.observability.contracts import RetrievalOutcome
from app.observability.operation import observe_operation
from app.rag.embeddings import Embedder
from app.rag.pipeline_version import get_pipeline_version

_FILE_INDEX_COLLECTION = "file-index"
_COLLECTION_ROLES = {
    "paper_evidence": "paper_evidence",
    "project_memory": "project_memory",
}


def _collection_role(collection: str) -> str:
    """Return the finite role name safe to attach to retrieval spans."""
    return _COLLECTION_ROLES.get(collection, "other")


def _safe_count(col: Any) -> Optional[int]:
    """Read a collection's size without ever raising into product code."""
    try:
        return col.count()
    except Exception:
        return None


class ChromaDBClient:
    """In-memory ChromaDB wrapper for the web demo deployment.

    State is persistent across process restarts. Files are deduplicated by
    SHA-256 content hash.
    """

    def __init__(
        self,
        embedder: Optional[Embedder] = None,
        *,
        path: str | Path | None = None,
        client: Any | None = None,
    ) -> None:
        import os
        if client is not None:
            self._client = client
        else:
            db_path = Path(path).expanduser() if path is not None else Path(os.path.expanduser("~/.studybuddy/chroma"))
            db_path.mkdir(parents=True, exist_ok=True)
            self._client = chromadb.PersistentClient(path=str(db_path))
        self.embedder = embedder or Embedder()

    # ------------------------------------------------------------------ #
    # Internal                                                             #
    # ------------------------------------------------------------------ #

    def _get_or_create(self, name: str):
        return self._client.get_or_create_collection(name)

    # ------------------------------------------------------------------ #
    # File dedup                                                           #
    # ------------------------------------------------------------------ #

    @staticmethod
    def file_hash(content: bytes) -> str:
        return hashlib.sha256(content).hexdigest()

    def is_indexed(self, content_hash: str) -> bool:
        try:
            col = self._client.get_or_create_collection(_FILE_INDEX_COLLECTION)
            r = col.get(ids=[content_hash])
            return len(r["ids"]) > 0
        except Exception:
            return False

    def mark_indexed(self, content_hash: str, filename: str) -> None:
        col = self._client.get_or_create_collection(_FILE_INDEX_COLLECTION)
        col.upsert(
            ids=[content_hash],
            documents=[filename],
            metadatas=[{"filename": filename}],
        )

    # ------------------------------------------------------------------ #
    # Public API                                                           #
    # ------------------------------------------------------------------ #

    def upsert(
        self,
        collection: str,
        documents: List[str],
        metadatas: List[Dict[str, Any]],
        ids: List[str],
    ) -> None:
        col = self._get_or_create(collection)
        with observe_operation("embedding.batch", subsystem="embedding") as op:
            op.add_count("embedding_batch_size", len(documents))
            embeddings = self.embedder.embed(documents)
        with observe_operation("chroma.upsert", subsystem="chroma") as op:
            before = _safe_count(col)
            if before is not None:
                op.set("collection_count_before", before)
            col.upsert(documents=documents, embeddings=embeddings, metadatas=metadatas, ids=ids)
            after = _safe_count(col)
            if after is not None:
                op.set("collection_count_after", after)

    def query_observed(
        self,
        collection: str,
        query_embedding: List[float],
        n_results: int = 5,
        where: Optional[Dict[str, Any]] = None,
        *,
        consumer: Optional[str] = None,
        pipeline_version: Optional[str] = None,
    ) -> RetrievalOutcome:
        """Vector search that preserves the *cause* of an empty result.

        Behaviourally identical to :meth:`query` (same clamping, same
        fallbacks), but returns a :class:`RetrievalOutcome` so a caller can
        tell a genuinely-empty successful search (``success_empty``) apart from
        a collection-lookup / vector-search failure that fell back to ``[]``
        (``error_fallback``). :meth:`query` delegates here and unwraps
        ``.candidates`` so every existing caller stays byte-for-byte compatible.

        ``pipeline_version`` (default ``None``) gates the over-fetch + dedup +
        truncate mechanism -- see ``app.rag.pipeline_version``. ``None`` (every
        ordinary product call site) and the explicit ``"rag-naive-v1"`` both
        resolve to the exact same frozen, unmodified behavior this method has
        always had: request exactly ``n_results``, no over-fetch, no dedup.
        Only an explicit ``"rag-dedup-v2"`` (driven by the benchmark runner)
        changes anything.
        """
        pv = get_pipeline_version(pipeline_version)
        requested_n = n_results  # what the caller actually wants back, unchanged by over-fetch

        with observe_operation("chroma.collection_lookup", subsystem="chroma", consumer=consumer) as op:
            try:
                col = self._client.get_collection(collection)
            except Exception as exc:
                op.mark_error(_obs_retrieval.COLLECTION_UNAVAILABLE)
                return RetrievalOutcome.failure(_obs_retrieval.COLLECTION_UNAVAILABLE)

        # Over-fetch a larger raw pool when dedup is enabled, so removing
        # near-duplicates below can still backfill up to `requested_n` from
        # alternate candidates instead of only ever shrinking the naive
        # top-k in place. `fetch_n == requested_n` whenever dedup is
        # disabled (v1's overfetch_factor is pinned to 1), which is what
        # keeps v1 byte-identical to its pre-Task-8 behavior.
        fetch_n = requested_n * pv.overfetch_factor

        # Clamp fetch_n to actual collection size (a count failure is
        # non-fatal here, exactly as before). `requested_top_k` is always the
        # caller's original ask, never the inflated over-fetch amount, so
        # this attribute means the same thing across both pipeline versions.
        with observe_operation("chroma.collection_count", subsystem="chroma", consumer=consumer) as op:
            try:
                actual = col.count()
                op.set("collection_size", actual)
                op.set("requested_top_k", requested_n)
                fetch_n = min(fetch_n, actual) if actual > 0 else 1
            except Exception:
                pass

        kwargs: Dict[str, Any] = {
            "query_embeddings": [query_embedding],
            "n_results": fetch_n,
        }
        if where:
            kwargs["where"] = where

        with observe_operation("chroma.vector_search", subsystem="chroma", consumer=consumer) as op:
            try:
                results = col.query(**kwargs)
            except Exception as exc:
                op.mark_error(_obs_retrieval.VECTOR_SEARCH_FAILED)
                return RetrievalOutcome.failure(_obs_retrieval.VECTOR_SEARCH_FAILED)

        with observe_operation("rag.result_prepare", subsystem="retrieval", consumer=consumer) as op:
            out = []
            for doc, meta in zip(results["documents"][0], results["metadatas"][0]):
                out.append({"text": doc, **meta})
            # Pool-level removed count, captured *before* truncation to
            # `requested_n` -- this is direct evidence of what the dedup
            # mechanism actually did to the raw over-fetched pool. It is
            # deliberately distinct from `duplicate_candidate_ratio` below,
            # which is computed on the final, already-deduplicated/truncated
            # list `candidate_stats()` receives: a post-dedup set is ~0.0 by
            # construction whether or not anything was removed, so it alone
            # cannot tell "removed nothing" apart from "removed something and
            # truncated it away". `duplicates_removed_from_pool` closes that
            # gap. Always 0 for v1 (dedup disabled -- there is no pool step to
            # remove anything from), which is itself the correct, honest value.
            duplicates_removed_from_pool = 0
            if pv.dedup_enabled:
                dup_indices = _obs_retrieval.find_duplicate_indices(out)
                duplicates_removed_from_pool = len(dup_indices)
                out = [c for i, c in enumerate(out) if i not in dup_indices]
                out = out[:requested_n]
            op.set("duplicates_removed_from_pool", duplicates_removed_from_pool)
            for key, value in _obs_retrieval.candidate_stats(out).items():
                op.set(key, value)

        outcome = RetrievalOutcome.success(out)
        return outcome

    def query(
        self,
        collection: str,
        query_embedding: List[float],
        n_results: int = 5,
        where: Optional[Dict[str, Any]] = None,
        *,
        pipeline_version: Optional[str] = None,
    ) -> List[Dict[str, Any]]:
        return self.query_observed(
            collection,
            query_embedding,
            n_results=n_results,
            where=where,
            pipeline_version=pipeline_version,
        ).candidates

    def query_raw(
        self,
        collection: str,
        query_embedding: List[float],
        n_results: int = 20,
        where: Optional[Dict[str, Any]] = None,
        *,
        consumer: Optional[str] = None,
        raise_on_failure: bool = False,
    ) -> List[Dict[str, Any]]:
        """Return stable IDs and distances for repository-level retrieval.

        Product consumers should use ``PaperEvidenceService`` or the project
        memory repository. This low-level method exists so rank fusion retains
        Chroma's identity and score instead of flattening results into text.
        ``raise_on_failure`` lets the structured source-retrieval boundary
        distinguish an unavailable dense index from a genuinely empty one;
        legacy callers retain the established empty-list fallback.
        """

        collection_role = _collection_role(collection)
        with observe_operation(
            "chroma.collection_lookup",
            subsystem="chroma",
            consumer=consumer,
            attributes={"collection_role": collection_role},
        ) as op:
            try:
                col = self._client.get_collection(collection)
            except NotFoundError:
                return []
            except Exception:
                op.mark_error(_obs_retrieval.COLLECTION_UNAVAILABLE)
                if raise_on_failure:
                    raise
                return []
        try:
            actual = col.count()
        except Exception:
            actual = n_results
        if actual <= 0:
            return []
        kwargs: Dict[str, Any] = {
            "query_embeddings": [query_embedding],
            "n_results": min(max(1, n_results), actual),
            "include": ["documents", "metadatas", "distances"],
        }
        if where:
            kwargs["where"] = where
        with observe_operation(
            "chroma.vector_search",
            subsystem="chroma",
            consumer=consumer,
            attributes={"collection_role": collection_role},
        ) as op:
            try:
                result = col.query(**kwargs)
            except Exception:
                op.mark_error(_obs_retrieval.VECTOR_SEARCH_FAILED)
                if raise_on_failure:
                    raise
                return []
        ids = (result.get("ids") or [[]])[0]
        documents = (result.get("documents") or [[]])[0]
        metadatas = (result.get("metadatas") or [[]])[0]
        distances = (result.get("distances") or [[]])[0]
        rows: List[Dict[str, Any]] = []
        for index, item_id in enumerate(ids):
            rows.append(
                {
                    "id": item_id,
                    "text": documents[index] if index < len(documents) else "",
                    "metadata": metadatas[index] if index < len(metadatas) else {},
                    "distance": distances[index] if index < len(distances) else None,
                }
            )
        return rows

    def delete_where(self, collection: str, where: Dict[str, Any]) -> None:
        """Delete matching rows, treating only an absent collection as empty.

        Derived-state deletion is a correctness operation.  Authentication,
        storage, or filter failures must reach the caller so source files are
        not removed while stale vectors remain searchable.
        """
        try:
            col = self._client.get_collection(collection)
        except NotFoundError:
            return
        col.delete(where=where)

    def get_where(
        self,
        collection: str,
        where: Dict[str, Any],
        *,
        include: List[str] | None = None,
    ) -> Dict[str, Any]:
        try:
            col = self._client.get_collection(collection)
        except NotFoundError:
            return {"ids": [], "metadatas": [], "documents": []}
        return col.get(where=where, include=include or ["metadatas"])

    def delete_ids(self, collection: str, ids: List[str]) -> None:
        if not ids:
            return
        try:
            col = self._client.get_collection(collection)
        except NotFoundError:
            return
        col.delete(ids=ids)

    def collection_count(self, collection: str) -> int:
        try:
            return self._client.get_collection(collection).count()
        except Exception:
            return 0

    def count_where(self, collection: str, where: Dict[str, Any]) -> int:
        try:
            result = self._client.get_collection(collection).get(where=where, include=[])
            return len(result.get("ids") or [])
        except Exception:
            return 0

    def delete_collection(self, name: str) -> None:
        try:
            self._client.delete_collection(name)
        except Exception:
            pass