ragkit-dev 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,251 @@
1
+ """STEP 6 - Store in Qdrant.
2
+
3
+ Two ways to run Qdrant:
4
+ server : docker run -p 6333:6333 -v qdrant_storage:/qdrant/storage qdrant/qdrant
5
+ local : set QDRANT_PATH=qdrant_data in .env (no server needed; one process
6
+ at a time)
7
+
8
+ Write side (`store_chunks`) creates the collection if needed. Read side
9
+ (`open_vector_store`) never creates anything: a missing store or collection, or
10
+ one built with a different embedding size, is an error with a fix in the message.
11
+
12
+ Re-indexing: every chunk gets a deterministic point ID built from its file name,
13
+ content hash and position. Ingesting a file again overwrites those points and
14
+ then deletes any older chunks of the same file name that were not overwritten,
15
+ so a document is *replaced*, never duplicated. `delete_document` removes one
16
+ document; `--recreate` drops the whole collection.
17
+ """
18
+
19
+ import logging
20
+ import time
21
+ import uuid
22
+ from pathlib import Path
23
+ from typing import Dict, List, Optional
24
+
25
+ from langchain_core.documents import Document
26
+ from langchain_qdrant import QdrantVectorStore
27
+ from qdrant_client import QdrantClient
28
+ from qdrant_client.http import models
29
+
30
+ from rag_kit.errors import EmptyDocumentError, RagError, VectorStoreError
31
+ from rag_kit.settings import Settings, default_settings
32
+ from rag_kit.step05_embedding import detect_dimension
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # Fixed namespace so the same chunk always maps to the same point ID.
37
+ _ID_NAMESPACE = uuid.UUID("8d2f1c3e-5b7a-4e0f-9c61-2a4b6d8e0f13")
38
+ _SOURCE_KEY = "metadata.source_file"
39
+
40
+
41
+ def describe_location(settings: Optional[Settings] = None) -> str:
42
+ settings = settings or default_settings()
43
+ if settings.qdrant_path:
44
+ return f"local Qdrant store {settings.qdrant_path}"
45
+ return f"Qdrant server {settings.qdrant_url}"
46
+
47
+
48
+ def get_client(create: bool = True, settings: Optional[Settings] = None) -> QdrantClient:
49
+ """Open Qdrant. With create=False a missing local store is an error instead
50
+ of being silently created empty."""
51
+ settings = settings or default_settings()
52
+ if settings.qdrant_path:
53
+ path = Path(settings.qdrant_path)
54
+ if not create and not path.exists():
55
+ raise VectorStoreError(
56
+ f"No local Qdrant store at {path}. Run `rag-kit ingest <file>` first, "
57
+ f"or point QDRANT_PATH at the existing store (relative paths are resolved "
58
+ f"from {settings.project_dir})."
59
+ )
60
+ try:
61
+ return QdrantClient(path=str(path))
62
+ except RuntimeError as exc:
63
+ if "already accessed" in str(exc):
64
+ raise VectorStoreError(
65
+ f"The local Qdrant store at {path} is in use by another process (the "
66
+ "API server, the Streamlit UI or another CLI command). Embedded Qdrant "
67
+ "allows one process at a time: stop the other one, or run a Qdrant "
68
+ "server and set QDRANT_URL instead of QDRANT_PATH."
69
+ ) from exc
70
+ raise VectorStoreError(f"Could not open {describe_location(settings)}: {exc}") from exc
71
+ return QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key)
72
+
73
+
74
+ def collection_exists(client: QdrantClient, name: str,
75
+ settings: Optional[Settings] = None) -> bool:
76
+ try:
77
+ return client.collection_exists(name)
78
+ except Exception as exc:
79
+ raise VectorStoreError(
80
+ f"Cannot reach {describe_location(settings)}: {exc}. Start the server "
81
+ "(docker run -p 6333:6333 qdrant/qdrant) or set QDRANT_PATH for local mode."
82
+ ) from exc
83
+
84
+
85
+ def collection_dimension(client: QdrantClient, name: str) -> int:
86
+ vectors = client.get_collection(name).config.params.vectors
87
+ if isinstance(vectors, dict): # named vectors; LangChain uses the unnamed one
88
+ vectors = vectors.get("") or next(iter(vectors.values()))
89
+ return vectors.size
90
+
91
+
92
+ def check_dimension(client: QdrantClient, name: str, expected: int,
93
+ settings: Optional[Settings] = None) -> None:
94
+ actual = collection_dimension(client, name)
95
+ if actual != expected:
96
+ raise VectorStoreError(
97
+ f"Collection '{name}' holds {actual}-dimensional vectors, but "
98
+ f"{(settings or default_settings()).embedding_model} produces {expected}. "
99
+ "Vectors from different models "
100
+ "are not comparable: set EMBEDDING_MODEL back to the model that built this "
101
+ "collection, use another COLLECTION_NAME, or rebuild it with --recreate."
102
+ )
103
+
104
+
105
+ def ensure_collection(client: QdrantClient, dimension: int, recreate: bool = False,
106
+ collection: Optional[str] = None,
107
+ settings: Optional[Settings] = None) -> None:
108
+ settings = settings or default_settings()
109
+ name = collection or settings.collection_name
110
+ exists = collection_exists(client, name, settings)
111
+
112
+ if exists and recreate:
113
+ client.delete_collection(name)
114
+ exists = False
115
+ logger.info(f" dropped old collection '{name}'")
116
+
117
+ if exists:
118
+ check_dimension(client, name, dimension, settings)
119
+ return
120
+
121
+ client.create_collection(
122
+ collection_name=name,
123
+ vectors_config=models.VectorParams(size=dimension, distance=models.Distance.COSINE),
124
+ )
125
+ logger.info(f" created collection '{name}' (dim={dimension}, cosine)")
126
+
127
+
128
+ def get_vector_store(embeddings, client: QdrantClient = None,
129
+ collection: Optional[str] = None,
130
+ settings: Optional[Settings] = None) -> QdrantVectorStore:
131
+ settings = settings or default_settings()
132
+ return QdrantVectorStore(
133
+ client=client or get_client(settings=settings),
134
+ collection_name=collection or settings.collection_name,
135
+ embedding=embeddings,
136
+ )
137
+
138
+
139
+ def open_vector_store(embeddings, client: QdrantClient,
140
+ collection: Optional[str] = None,
141
+ settings: Optional[Settings] = None) -> QdrantVectorStore:
142
+ """Read side: validate that the collection exists and matches the model."""
143
+ settings = settings or default_settings()
144
+ name = collection or settings.collection_name
145
+ if not collection_exists(client, name, settings):
146
+ found = [c.name for c in client.get_collections().collections]
147
+ raise VectorStoreError(
148
+ f"Collection '{name}' does not exist in {describe_location(settings)} "
149
+ f"(found: {', '.join(found) or 'none'}). Ingest a document first, or fix "
150
+ "COLLECTION_NAME."
151
+ )
152
+ check_dimension(client, name, detect_dimension(embeddings, settings), settings)
153
+ return get_vector_store(embeddings, client, name, settings)
154
+
155
+
156
+ def chunk_point_id(metadata: dict) -> str:
157
+ key = f"{metadata['source_file']}|{metadata.get('doc_id')}|{metadata['chunk_id']}"
158
+ return str(uuid.uuid5(_ID_NAMESPACE, key))
159
+
160
+
161
+ def _source_filter(source_file: str, keep_ids: Optional[List[str]] = None) -> models.Filter:
162
+ return models.Filter(
163
+ must=[models.FieldCondition(key=_SOURCE_KEY, match=models.MatchValue(value=source_file))],
164
+ must_not=[models.HasIdCondition(has_id=keep_ids)] if keep_ids else None,
165
+ )
166
+
167
+
168
+ def store_chunks(chunks: List[Document], embeddings, recreate: bool = False,
169
+ client: QdrantClient = None, collection: Optional[str] = None,
170
+ settings: Optional[Settings] = None) -> dict:
171
+ if not chunks:
172
+ raise EmptyDocumentError("Nothing to store: the document produced no chunks.")
173
+
174
+ settings = settings or default_settings()
175
+ client = client or get_client(settings=settings)
176
+ name = collection or settings.collection_name
177
+ ensure_collection(client, detect_dimension(embeddings, settings), recreate=recreate,
178
+ collection=name, settings=settings)
179
+ store = get_vector_store(embeddings, client, name, settings)
180
+ ids = [chunk_point_id(c.metadata) for c in chunks]
181
+
182
+ # Gemini caps a single embed call at 100 texts, so send in batches.
183
+ # On the free tier it also caps ~100 texts/minute, so pause between batches.
184
+ batch = settings.embed_batch_size
185
+ for i, start in enumerate(range(0, len(chunks), batch)):
186
+ if i > 0 and settings.embed_sleep:
187
+ time.sleep(settings.embed_sleep)
188
+ try:
189
+ store.add_documents(chunks[start:start + batch], ids=ids[start:start + batch])
190
+ except RagError:
191
+ raise
192
+ except Exception as exc:
193
+ raise VectorStoreError(
194
+ f"Storing chunks failed after {start}/{len(chunks)}: {exc}. Running ingest "
195
+ "again is safe - chunks are keyed by file and position, so nothing is "
196
+ "duplicated."
197
+ ) from exc
198
+ logger.info(f" stored {min(start + batch, len(chunks))}/{len(chunks)} chunks")
199
+
200
+ # Replace, not append: drop older chunks of the same file that this run
201
+ # did not overwrite (e.g. the file shrank or its content changed).
202
+ replaced = 0
203
+ for source in sorted({c.metadata["source_file"] for c in chunks}):
204
+ source_ids = [pid for pid, c in zip(ids, chunks) if c.metadata["source_file"] == source]
205
+ stale = _source_filter(source, keep_ids=source_ids)
206
+ count = client.count(name, count_filter=stale, exact=True).count
207
+ if count:
208
+ client.delete(name, points_selector=models.FilterSelector(filter=stale))
209
+ replaced += count
210
+
211
+ total = client.count(name, exact=True).count
212
+ note = f", {replaced} outdated chunks removed" if replaced else ""
213
+ logger.info(f"[6] Qdrant : collection '{name}' now holds {total} vectors{note}")
214
+ return {"collection": name, "stored": len(chunks), "removed_outdated": replaced,
215
+ "total": total}
216
+
217
+
218
+ def delete_document(client: QdrantClient, source_file: str,
219
+ collection: Optional[str] = None,
220
+ settings: Optional[Settings] = None) -> int:
221
+ """Remove every chunk of one file. Returns how many were deleted."""
222
+ settings = settings or default_settings()
223
+ name = collection or settings.collection_name
224
+ if not collection_exists(client, name, settings):
225
+ raise VectorStoreError(
226
+ f"Collection '{name}' does not exist in {describe_location(settings)}.")
227
+ selector = _source_filter(source_file)
228
+ count = client.count(name, count_filter=selector, exact=True).count
229
+ if count:
230
+ client.delete(name, points_selector=models.FilterSelector(filter=selector))
231
+ return count
232
+
233
+
234
+ def list_documents(client: QdrantClient, collection: Optional[str] = None,
235
+ settings: Optional[Settings] = None) -> Dict[str, int]:
236
+ """File name -> number of chunks stored for it."""
237
+ settings = settings or default_settings()
238
+ name = collection or settings.collection_name
239
+ if not collection_exists(client, name, settings):
240
+ raise VectorStoreError(
241
+ f"Collection '{name}' does not exist in {describe_location(settings)}.")
242
+ counts: Dict[str, int] = {}
243
+ offset = None
244
+ while True:
245
+ points, offset = client.scroll(name, limit=512, offset=offset,
246
+ with_payload=True, with_vectors=False)
247
+ for point in points:
248
+ source = (point.payload or {}).get("metadata", {}).get("source_file", "(unknown)")
249
+ counts[source] = counts.get(source, 0) + 1
250
+ if offset is None:
251
+ return dict(sorted(counts.items()))
@@ -0,0 +1,17 @@
1
+ """STEP 8 - Query embedding.
2
+
3
+ The question is turned into a vector with the same model used in step 5.
4
+ LangChain's retriever would do this internally, but doing it explicitly keeps
5
+ the pipeline visible and lets you inspect or cache the vector.
6
+ """
7
+
8
+ import logging
9
+ from typing import List
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def embed_question(embeddings, question: str) -> List[float]:
15
+ vector = embeddings.embed_query(question)
16
+ logger.info(f"[8] Query vec : {len(vector)} dimensions")
17
+ return vector
@@ -0,0 +1,34 @@
1
+ """STEP 9 - Retrieve relevant chunks.
2
+
3
+ Cosine similarity search in Qdrant against the query vector from step 8.
4
+ Returns (Document, score) pairs, highest score first. With a `min_score`
5
+ (MIN_SCORE in .env), weaker hits are dropped, and an empty result means
6
+ "nothing relevant" rather than "the k least-bad chunks".
7
+ """
8
+
9
+ import logging
10
+ from typing import List, Optional, Tuple
11
+
12
+ from langchain_core.documents import Document
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def retrieve(vector_store, query_vector: List[float], k: int,
18
+ min_score: Optional[float] = None) -> List[Tuple[Document, float]]:
19
+ results = vector_store.similarity_search_with_score_by_vector(query_vector, k=k)
20
+
21
+ dropped = 0
22
+ if min_score is not None:
23
+ kept = [(doc, score) for doc, score in results if score >= min_score]
24
+ dropped = len(results) - len(kept)
25
+ results = kept
26
+
27
+ note = f" ({dropped} below min score {min_score})" if dropped else ""
28
+ logger.info(f"[9] Retrieved : {len(results)} chunks{note}")
29
+ for i, (doc, score) in enumerate(results, start=1):
30
+ page = doc.metadata.get("page_number") or "-"
31
+ preview = doc.page_content[:70].replace("\n", " ")
32
+ logger.info(f" S{i} page {page} | score {score:.3f} | {preview}...")
33
+
34
+ return results
@@ -0,0 +1,84 @@
1
+ """STEP 10 - Gemini.
2
+
3
+ The retrieved chunks become the context. The prompt forces the model to answer
4
+ from that context only, and to tag every claim with the [S1]..[Sn] marker of
5
+ the chunk it came from - that is what makes step 11's sources trustworthy.
6
+ """
7
+
8
+ import logging
9
+ from typing import List, Optional, Tuple
10
+
11
+ from langchain_core.documents import Document
12
+ from langchain_core.output_parsers import StrOutputParser
13
+ from langchain_core.prompts import ChatPromptTemplate
14
+
15
+ from rag_kit.errors import GenerationError
16
+ from rag_kit.settings import Settings, default_settings
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ NO_RESULTS_ANSWER = "Nothing relevant was retrieved from the documents for this question."
21
+
22
+ SYSTEM_PROMPT = """You answer questions about a set of documents.
23
+
24
+ Rules:
25
+ - Use ONLY the context below. Do not add outside knowledge.
26
+ - After each claim, put the marker of the chunk it came from, like [S1] or [S2].
27
+ - If the context does not contain the answer, say exactly that the document does
28
+ not cover it. Do not guess.
29
+ - Answer in the same language the question was asked in.
30
+ - Be direct. No preamble."""
31
+
32
+ PROMPT = ChatPromptTemplate.from_messages([
33
+ ("system", SYSTEM_PROMPT),
34
+ ("human", "Context:\n{context}\n\nQuestion: {question}"),
35
+ ])
36
+
37
+
38
+ def get_llm(settings: Optional[Settings] = None) -> "ChatGoogleGenerativeAI": # noqa: F821
39
+ settings = settings or default_settings()
40
+ settings.require_api_key()
41
+ # Imported here so ingestion and retrieval work without the Gemini SDK.
42
+ try:
43
+ from langchain_google_genai import ChatGoogleGenerativeAI
44
+ except ImportError as exc:
45
+ raise GenerationError(
46
+ f'Answer generation needs the gemini extra: pip install "ragkit-dev[gemini]" ({exc}).'
47
+ ) from exc
48
+ return ChatGoogleGenerativeAI(
49
+ model=settings.llm_model,
50
+ google_api_key=settings.google_api_key,
51
+ temperature=settings.temperature,
52
+ )
53
+
54
+
55
+ def format_context(scored_docs: List[Tuple[Document, float]]) -> str:
56
+ blocks = []
57
+ for i, (doc, _score) in enumerate(scored_docs, start=1):
58
+ page = doc.metadata.get("page_number")
59
+ source = doc.metadata.get("source_file", "document")
60
+ where = f"{source}, page {page}" if page else source
61
+ blocks.append(f"[S{i}] ({where})\n{doc.page_content}")
62
+ return "\n\n---\n\n".join(blocks)
63
+
64
+
65
+ def generate_answer(question: str, scored_docs: List[Tuple[Document, float]], llm=None,
66
+ settings: Optional[Settings] = None) -> str:
67
+ if not scored_docs:
68
+ return NO_RESULTS_ANSWER
69
+
70
+ settings = settings or default_settings()
71
+ chain = PROMPT | (llm or get_llm(settings)) | StrOutputParser()
72
+ try:
73
+ answer = chain.invoke({
74
+ "context": format_context(scored_docs),
75
+ "question": question,
76
+ })
77
+ except Exception as exc:
78
+ raise GenerationError(
79
+ f"{settings.llm_model} could not answer: {exc}. Check GOOGLE_API_KEY, the "
80
+ "LLM_MODEL name and your quota (HTTP 429 means rate-limited; wait and retry)."
81
+ ) from exc
82
+
83
+ logger.info(f"[10] Gemini : {settings.llm_model} answered")
84
+ return answer.strip()
@@ -0,0 +1,60 @@
1
+ """STEP 11 - Answer + sources."""
2
+
3
+ import re
4
+ from typing import List, Tuple
5
+
6
+ from langchain_core.documents import Document
7
+
8
+ # Gemini tags claims with [S1], [S2]... (sometimes back-to-back).
9
+ _MARKER_RE = re.compile(r"[ \t]*(?:\[S\d+\])+")
10
+
11
+
12
+ def strip_markers(text: str) -> str:
13
+ """Remove [S1]-style citation markers, e.g. for a UI that lists sources separately."""
14
+ return _MARKER_RE.sub("", text).strip()
15
+
16
+
17
+ def build_sources(scored_docs: List[Tuple[Document, float]]) -> List[dict]:
18
+ """One entry per retrieved chunk; `marker` matches the [S1].. tags in the answer."""
19
+ return [
20
+ {
21
+ "marker": f"S{i}",
22
+ "page": doc.metadata.get("page_number"),
23
+ "file": doc.metadata.get("source_file"),
24
+ "score": float(score),
25
+ "chunk_id": doc.metadata.get("chunk_id"),
26
+ "doc_id": doc.metadata.get("doc_id"),
27
+ "text": doc.page_content,
28
+ }
29
+ for i, (doc, score) in enumerate(scored_docs, start=1)
30
+ ]
31
+
32
+
33
+ def format_sources(scored_docs: List[Tuple[Document, float]], preview_chars: int = 160) -> str:
34
+ lines = []
35
+ for i, (doc, score) in enumerate(scored_docs, start=1):
36
+ page = doc.metadata.get("page_number")
37
+ source = doc.metadata.get("source_file", "document")
38
+ where = f"{source}, page {page}" if page else source
39
+ preview = " ".join(doc.page_content[:preview_chars].split())
40
+ lines.append(f" [S{i}] {where} (score {score:.3f})\n {preview}...")
41
+ return "\n".join(lines)
42
+
43
+
44
+ def present(question: str, answer: str, scored_docs: List[Tuple[Document, float]]) -> dict:
45
+ print("\n" + "=" * 72)
46
+ print(f"Q: {question}")
47
+ print("-" * 72)
48
+ print(answer)
49
+ print("-" * 72)
50
+ print("Sources:" if scored_docs else "Sources: none (nothing relevant was retrieved)")
51
+ if scored_docs:
52
+ print(format_sources(scored_docs))
53
+ print("=" * 72)
54
+
55
+ return {
56
+ "question": question,
57
+ "answer": answer,
58
+ "status": "answered" if scored_docs else "no_results",
59
+ "sources": build_sources(scored_docs),
60
+ }
rag_kit/ui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """The optional Streamlit test UI (`rag-kit ui`); app.py is run by streamlit."""
rag_kit/ui/app.py ADDED
@@ -0,0 +1,153 @@
1
+ """Optional Streamlit UI for testing the pipeline - not a production app.
2
+
3
+ pip install "ragkit-dev[ui]"
4
+ rag-kit ui # http://localhost:8501
5
+
6
+ Upload and ingest documents, ask questions, and inspect the retrieved chunks.
7
+ Uploads are saved under the project's uploads/ folder (git-ignored). Local
8
+ Qdrant allows one process at a time, so stop `serve` or other CLI commands
9
+ while this runs. `rag-kit ui` passes the project in RAG_PROJECT_DIR.
10
+ """
11
+
12
+ from pathlib import Path
13
+
14
+ import streamlit as st
15
+
16
+ from rag_kit.env import export_env_file
17
+ from rag_kit.errors import RagError
18
+ from rag_kit.log import show_progress
19
+ from rag_kit.pipeline import Pipeline
20
+ from rag_kit.settings import SUPPORTED_EXTENSIONS, default_settings
21
+ from rag_kit.step05_embedding import get_embeddings
22
+ from rag_kit.step06_vector_store import describe_location, get_client
23
+
24
+ st.set_page_config(page_title="RAG toolkit - test UI", layout="wide")
25
+ show_progress() # step progress in the terminal running streamlit, as before
26
+ try:
27
+ settings = default_settings()
28
+ except RagError as exc: # no rag-kit project found
29
+ st.error(str(exc))
30
+ st.stop()
31
+ export_env_file(settings) # HF_HOME, proxies etc. before the model loads
32
+ UPLOAD_DIR = settings.project_dir / "uploads"
33
+
34
+
35
+ @st.cache_resource(show_spinner="Loading embedding model...")
36
+ def shared_embeddings():
37
+ return get_embeddings(settings)
38
+
39
+
40
+ @st.cache_resource(show_spinner="Opening Qdrant...")
41
+ def shared_client():
42
+ return get_client(create=True, settings=settings)
43
+
44
+
45
+ def pipeline_for(collection: str) -> Pipeline:
46
+ # One client and one model for the whole app; a Pipeline per collection.
47
+ cache = st.session_state.setdefault("pipelines", {})
48
+ if collection not in cache:
49
+ cache[collection] = Pipeline(collection=collection, embeddings=shared_embeddings(),
50
+ client=shared_client(), settings=settings)
51
+ return cache[collection]
52
+
53
+
54
+ def show_error(exc: Exception) -> None:
55
+ st.error(str(exc) if isinstance(exc, (RagError, ValueError)) else f"Unexpected error: {exc!r}")
56
+
57
+
58
+ # --- sidebar: settings + indexed documents -------------------------------------
59
+ with st.sidebar:
60
+ st.header("Settings")
61
+ collection = st.text_input("Collection", value=settings.collection_name).strip()
62
+ top_k = st.slider("Chunks to retrieve (top k)", 1, 20, settings.top_k)
63
+ min_score = st.number_input("Min score (0 = keep all)", 0.0, 1.0,
64
+ float(settings.min_score or 0.0), 0.05)
65
+ st.caption(f"Store: {describe_location(settings)}")
66
+ st.caption(f"Embedding: {settings.embedding_model} \nLLM: {settings.llm_model}")
67
+ if not settings.google_api_key:
68
+ st.warning("GOOGLE_API_KEY is not set - retrieval works, answers will fail.")
69
+
70
+ st.header("Indexed documents")
71
+ try:
72
+ docs = pipeline_for(collection).documents()
73
+ if docs:
74
+ st.dataframe([{"file": k, "chunks": v} for k, v in docs.items()],
75
+ hide_index=True, use_container_width=True)
76
+ else:
77
+ st.caption("Collection is empty.")
78
+ except RagError as exc:
79
+ st.caption(str(exc))
80
+
81
+ if not collection:
82
+ st.stop()
83
+ pipeline = pipeline_for(collection)
84
+
85
+ st.title("RAG toolkit - test UI")
86
+ tab_ask, tab_ingest = st.tabs(["Ask", "Ingest"])
87
+
88
+ # --- ingest -----------------------------------------------------------------------
89
+ with tab_ingest:
90
+ uploads = st.file_uploader("Documents", type=[e.lstrip(".") for e in SUPPORTED_EXTENSIONS],
91
+ accept_multiple_files=True)
92
+ font_repair = st.checkbox("PDF font repair", value=settings.pdf_font_repair,
93
+ help="Only for PDFs whose text extracts as scrambled glyphs.")
94
+ st.caption(f"Re-ingesting a file with the same name replaces its chunks in '{collection}'.")
95
+ if st.button("Ingest", disabled=not uploads, type="primary"):
96
+ UPLOAD_DIR.mkdir(exist_ok=True)
97
+ for upload in uploads:
98
+ path = UPLOAD_DIR / Path(upload.name).name
99
+ path.write_bytes(upload.getbuffer())
100
+ with st.status(f"Ingesting {path.name}...") as status:
101
+ try:
102
+ report = pipeline.ingest(path, font_repair=font_repair)
103
+ status.update(label=f"{path.name}: {report['stored']} chunks from "
104
+ f"{report['pages']} page(s), collection now holds "
105
+ f"{report['total']}", state="complete")
106
+ if report["removed_outdated"]:
107
+ st.caption(f"Removed {report['removed_outdated']} outdated chunks.")
108
+ except Exception as exc: # noqa: BLE001 - shown in the UI
109
+ status.update(label=f"{path.name}: failed", state="error")
110
+ show_error(exc)
111
+
112
+ # --- ask ----------------------------------------------------------------------------
113
+ with tab_ask:
114
+ question = st.text_area("Question", placeholder="What does the document say about ...?")
115
+ col_ask, col_search = st.columns([1, 1])
116
+ run_ask = col_ask.button("Ask", type="primary", disabled=not question.strip())
117
+ run_search = col_search.button("Retrieve only (no LLM)", disabled=not question.strip())
118
+
119
+ if run_ask or run_search:
120
+ try:
121
+ with st.spinner("Working..."):
122
+ if run_ask:
123
+ result = pipeline.ask(question, k=top_k, min_score=min_score or None)
124
+ scored = result.scored_docs
125
+ else:
126
+ result = None
127
+ scored = pipeline.search(question, k=top_k, min_score=min_score or None)
128
+ except Exception as exc: # noqa: BLE001 - shown in the UI
129
+ show_error(exc)
130
+ st.stop()
131
+
132
+ if result is not None:
133
+ st.subheader("Answer")
134
+ if result.status == "no_results":
135
+ st.info(result.answer)
136
+ else:
137
+ st.markdown(result.answer)
138
+
139
+ st.subheader(f"Retrieved chunks ({len(scored)})")
140
+ if not scored:
141
+ st.info("No chunks were retrieved. Lower the min score or ingest a document.")
142
+ st.dataframe([
143
+ {"marker": f"S{i}", "score": round(score, 4), "file": d.metadata.get("source_file"),
144
+ "page": d.metadata.get("page_number"), "chunk": d.metadata.get("chunk_id"),
145
+ "chars": len(d.page_content)}
146
+ for i, (d, score) in enumerate(scored, start=1)
147
+ ], hide_index=True, use_container_width=True)
148
+ for i, (doc, score) in enumerate(scored, start=1):
149
+ page = doc.metadata.get("page_number")
150
+ label = f"S{i} - {doc.metadata.get('source_file')}" + (f", page {page}" if page else "")
151
+ with st.expander(f"{label} (score {score:.3f})"):
152
+ st.text(doc.page_content)
153
+ st.json(doc.metadata, expanded=False)