gsas2-query 0.3.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,333 @@
1
+ Metadata-Version: 2.5
2
+ Name: gsas2-query
3
+ Version: 0.3.0
4
+ Summary: Query-GSAS-II Documentation Assistant — semantic search and AI answers over GSAS-II tutorials and help pages
5
+ Project-URL: Repository, https://github.com/AdvancedPhotonSource/Query-GSAS-II
6
+ Author-email: Pawan Tripathi <ptripathi@anl.gov>, Brian Toby <toby@anl.gov>
7
+ License: MIT
8
+ Keywords: crystallography,documentation,gsas,rag,xrd
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: beautifulsoup4>=4.12.0
16
+ Requires-Dist: chromadb>=0.5.0
17
+ Requires-Dist: fastapi>=0.110.0
18
+ Requires-Dist: fastembed>=0.3.0
19
+ Requires-Dist: httpx>=0.27.0
20
+ Requires-Dist: lxml>=5.0.0
21
+ Requires-Dist: pydantic>=2.0.0
22
+ Requires-Dist: pypdf>=4.0.0
23
+ Requires-Dist: python-dotenv>=1.0.0
24
+ Requires-Dist: requests>=2.31.0
25
+ Requires-Dist: uvicorn[standard]>=0.29.0
26
+ Provides-Extra: anthropic
27
+ Requires-Dist: anthropic>=0.25.0; extra == 'anthropic'
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8.0; extra == 'dev'
30
+ Provides-Extra: gui
31
+ Requires-Dist: wxpython>=4.2.0; extra == 'gui'
32
+ Provides-Extra: llama-cpp
33
+ Requires-Dist: llama-cpp-python>=0.2.0; extra == 'llama-cpp'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # gsas2-query — GSAS-II Documentation Assistant
37
+
38
+ Semantic search + AI answers over the full GSAS-II documentation set:
39
+ **129 HTML pages** (home, help, and all 62 tutorials) plus the
40
+ **Programmer's Guide** and **Powder Crystallography** book PDFs.
41
+
42
+ Answers include **inline citations** — every `[N]` in the response is a
43
+ clickable link to the exact documentation section that supported that sentence.
44
+
45
+ All embedding and retrieval runs on your machine — no data is sent externally
46
+ unless you explicitly choose the Anthropic API backend.
47
+
48
+ ---
49
+
50
+ ## Installation
51
+
52
+ ### pip
53
+
54
+ ```bash
55
+ pip install gsas2-query
56
+ ```
57
+
58
+ Or install directly from source:
59
+
60
+ ```bash
61
+ pip install git+https://github.com/AdvancedPhotonSource/Query-GSAS-II.git
62
+ ```
63
+
64
+ ### conda
65
+
66
+ ```bash
67
+ conda install -c conda-forge gsas2-query
68
+ ```
69
+
70
+ ### Into an existing GSAS-II environment
71
+
72
+ ```bash
73
+ # Activate the GSAS-II conda environment first, then:
74
+ pip install gsas2-query
75
+ ```
76
+
77
+ ### Development / editable install
78
+
79
+ ```bash
80
+ git clone https://github.com/AdvancedPhotonSource/Query-GSAS-II.git
81
+ cd Query-GSAS-II
82
+ pip install -e ".[dev]"
83
+ ```
84
+
85
+ ---
86
+
87
+ ## Ollama — free local LLM (recommended when llama.cpp is not installed)
88
+
89
+ ```bash
90
+ brew install ollama # macOS; see https://ollama.com for other platforms
91
+ ollama serve & # start the local server
92
+ ollama pull llama3 # ~5 GB one-time download
93
+ ```
94
+
95
+ Other model options: `llama3:70b` (better quality, ~40 GB), `mistral` (faster, ~4 GB).
96
+
97
+ ---
98
+
99
+ ## llama-cpp-python — in-process local LLM (auto-selected when installed)
100
+
101
+ `llama-cpp-python` runs a GGUF model directly inside the Python process — no
102
+ separate server required. It is cross-platform and available from conda-forge.
103
+
104
+ When `llama-cpp-python` is importable, gsas2-query selects it **automatically**
105
+ without any `LLM_BACKEND` setting.
106
+
107
+ ### Install
108
+
109
+ ```bash
110
+ conda install -c conda-forge llama-cpp-python # recommended
111
+ # or:
112
+ pip install "gsas2-query[llama_cpp]"
113
+ ```
114
+
115
+ ### Download a GGUF model
116
+
117
+ Download any GGUF-format model from Hugging Face, for example:
118
+
119
+ ```bash
120
+ # Llama 3.1 8B (Q4_K_M quantisation, ~5 GB):
121
+ wget https://huggingface.co/bartowski/Meta-Llama-3.1-8B-Instruct-GGUF/resolve/main/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf
122
+ ```
123
+
124
+ ### Configure
125
+
126
+ Set the model path in your `.env` or environment:
127
+
128
+ ```env
129
+ LLAMA_CPP_MODEL=/path/to/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf
130
+ # Optional tuning:
131
+ # LLAMA_CPP_N_CTX=4096 # context window (default: 4096)
132
+ # LLAMA_CPP_MAX_TOKENS=1500 # max tokens to generate (default: 1500)
133
+ ```
134
+
135
+ ---
136
+
137
+ ## First-time setup — index the documentation
138
+
139
+ Run once, or again when the docs are updated. Fetches ~130 web pages and 2 PDFs,
140
+ embeds everything locally. Takes ~10–20 minutes.
141
+
142
+ By default the index is stored in `~/.GSASII/gsas_query/chroma_db`
143
+ (see [User data directory](#userdatadirectory) for details).
144
+
145
+ ```bash
146
+ gsas2-query --setup # all sources (HTML + PDFs)
147
+ gsas2-query --setup --html-only # skip PDFs, faster (~5 min)
148
+ gsas2-query --setup --reset # drop index and rebuild from scratch
149
+ ```
150
+
151
+ HTML-only index is 21 Mb. With GSAS-II Programmer's Guide, 41 Mb; with
152
+ textbook as well, 53 Mb.
153
+
154
+ ---
155
+
156
+ ## Usage
157
+
158
+ ### Command-line — single question
159
+
160
+ ```bash
161
+ gsas2-query "How do I set up a sequential refinement?"
162
+ gsas2-query "What parameters control the background in Rietveld?"
163
+ gsas2-query "How do I export a CIF for publication?"
164
+ ```
165
+
166
+ ### Command-line — interactive REPL
167
+
168
+ Multi-turn conversation that remembers previous questions in the session.
169
+
170
+ ```bash
171
+ gsas2-query
172
+ ```
173
+
174
+ ```
175
+ GSAS-II Documentation Assistant
176
+ ════════════════════════════════════════════════════════════════════════════════
177
+
178
+ Knowledge base: 3,847 indexed chunks.
179
+ LLM backend: ollama
180
+ Type your question and press Enter. 'clear' resets history, 'quit' exits.
181
+
182
+ ────────────────────────────────────────────────────────────────────────────────
183
+ You: How do I constrain lattice parameters?
184
+ Thinking…
185
+ Assistant: To constrain lattice parameters in GSAS-II, open the Constraints
186
+ tab in the Phase panel [1]…
187
+
188
+ Sources:
189
+ [94%] Help: Phase General › Constraints
190
+ https://advancedphotonsource.github.io/GSAS-II-tutorials/help/phasegeneral.html
191
+ ```
192
+
193
+ Commands inside the REPL: `clear` (reset history), `quit` / `exit` (exit).
194
+
195
+ ### Desktop GUI
196
+
197
+ Opens a standalone floating dialog — stays open while you work in GSAS-II.
198
+
199
+ ```bash
200
+ gsas2-query --gui
201
+ ```
202
+
203
+ ### Embed in GSAS-II Help menu
204
+
205
+ Add one call to the GSAS-II menu handler (e.g. in `GSASIIctrl.py`):
206
+
207
+ ```python
208
+ def OnDocAssistant(self, event):
209
+ try:
210
+ from gsas_query.gui import show_assistant
211
+ show_assistant(self) # self = GSAS-II main frame
212
+ except ImportError:
213
+ wx.MessageBox(
214
+ "GSAS-II Assistant not installed.\n"
215
+ "Run: pip install gsas2-query",
216
+ "Not available"
217
+ )
218
+ ```
219
+
220
+ `show_assistant()` is idempotent — calling it a second time raises the existing
221
+ window rather than opening a duplicate.
222
+
223
+ ### Web UI
224
+
225
+ ```bash
226
+ gsas2-query-web # serves on 0.0.0.0:8000
227
+ HOST=127.0.0.1 PORT=8765 gsas2-query-web
228
+ ```
229
+
230
+ Then open `http://localhost:8000` in a browser. The web UI shows inline citations
231
+ as clickable superscript links and source chips below each answer.
232
+
233
+ ---
234
+
235
+ ## CLI flags reference
236
+
237
+ | Flag | Description |
238
+ |---|---|
239
+ | `--setup` | Index all documentation sources |
240
+ | `--setup --reset` | Drop the existing index and rebuild |
241
+ | `--setup --html-only` | Index HTML only, skip PDFs |
242
+ | `--gui` | Open the wxPython desktop assistant |
243
+ | `--backend ollama\|anthropic\|retrieval\|llama_cpp` | Override `LLM_BACKEND` env var |
244
+ | `--model <name>` | Override OLLAMA_MODEL, ANTHROPIC_MODEL, or LLAMA_CPP_MODEL |
245
+ | `--stats` | Show chunk count, backend, and DB path |
246
+
247
+ ---
248
+
249
+ ## LLM backend configuration
250
+
251
+ Backend selection precedence:
252
+
253
+ 1. `LLM_BACKEND` env var (or `--backend` CLI flag) **always takes effect when set**.
254
+ 2. If `LLM_BACKEND` is **not set** and `llama-cpp-python` is importable, `llama_cpp` is selected automatically.
255
+ 3. Otherwise the default is `ollama`.
256
+
257
+ | Backend | Config | Notes |
258
+ |---|---|---|
259
+ | `llama_cpp` (auto) | `LLAMA_CPP_MODEL=/path/to/model.gguf` | In-process, no daemon needed; auto-selected when llama-cpp-python is installed |
260
+ | `ollama` (default) | `OLLAMA_MODEL=llama3` | Free, fully local — no data leaves the network |
261
+ | `anthropic` | `ANTHROPIC_API_KEY=sk-ant-…` | Better answers; queries sent to Anthropic |
262
+ | `retrieval` | — | No LLM — returns raw matched chunks; useful offline or for testing |
263
+
264
+ ```bash
265
+ gsas2-query --backend retrieval "What is Le Bail extraction?"
266
+ gsas2-query --backend ollama --model mistral "How do I index peaks?"
267
+ gsas2-query --backend llama_cpp --model /path/to/model.gguf "How do I refine a structure?"
268
+ ```
269
+
270
+ ---
271
+
272
+ ## Inline citations
273
+
274
+ When using Ollama, llama_cpp, or Anthropic backends, answers contain `[N]` markers inline.
275
+ In the **web UI** these render as clickable superscript links opening the exact
276
+ source section. In the **CLI**, source URLs are listed below the answer with
277
+ relevance scores.
278
+
279
+ ---
280
+
281
+ ## Doc sources
282
+
283
+ | Category | Count |
284
+ |---|---|
285
+ | Home / installation pages | 22 |
286
+ | Help pages (all sections) | 42 |
287
+ | Tutorials | 62 |
288
+ | Programmer's Guide (PDF, readthedocs) | 1 |
289
+ | Powder Crystallography book (PDF, auto-fetches latest release) | 1 |
290
+ | **Total** | **128 sources** |
291
+
292
+ All HTML sources are fetched from
293
+ `https://advancedphotonsource.github.io/GSAS-II-tutorials/`.
294
+ The book PDF is fetched from the latest GitHub release of
295
+ [briantoby/PowderCrystallography](https://github.com/briantoby/PowderCrystallography/releases).
296
+
297
+ ---
298
+
299
+ ## Re-indexing when docs update
300
+
301
+ ```bash
302
+ gsas2-query --setup --reset
303
+ ```
304
+
305
+ Or trigger via the web API (requires `ADMIN_KEY` set in `.env`):
306
+
307
+ ```bash
308
+ curl -X POST http://localhost:8000/ingest -H "X-Admin-Key: your-key"
309
+ ```
310
+
311
+ ---
312
+
313
+ ## Security and deployment notes
314
+
315
+ - All embeddings and vector search run locally (sentence-transformers, ChromaDB).
316
+ - Ollama runs entirely on-premises — no queries leave the network.
317
+ - The `anthropic` backend sends question text and retrieved doc chunks to Anthropic.
318
+ Do not use it in air-gapped or data-sensitive environments.
319
+ - The web server applies per-IP rate limiting (default 30 req/min, configurable
320
+ via `RATE_LIMIT_RPM` in `.env`).
321
+ - The `/ingest` endpoint is protected by `X-Admin-Key`; leave `ADMIN_KEY` blank
322
+ to disable remote re-indexing.
323
+
324
+ ---
325
+
326
+ ## User data directory
327
+
328
+ The ChromaDB index is stored at `~/.GSASII/gsas_query/chroma_db` by default.
329
+ Override with the `GSAS_QUERY_DATA_DIR` environment variable:
330
+
331
+ ```bash
332
+ GSAS_QUERY_DATA_DIR=/data/gsas_query gsas2-query --setup
333
+ ```
@@ -0,0 +1,16 @@
1
+ gsas_query/__init__.py,sha256=1o5aDYXWQTFxxsDBy3fhyXS5AbPvu66AxIYjxOkfkLo,689
2
+ gsas_query/_embed.py,sha256=RQetKblVR95BpRQkJkYt_DJD3olIK1bZfuruIk2zOks,1478
3
+ gsas_query/_paths.py,sha256=i9fkshKzVTlBypc89AEdmyRgYdpYCZP_n5mXqEA5utE,787
4
+ gsas_query/_web.py,sha256=t0GLAt7XLTYaOWF16rv0T7HmsCt_mtDvKVAxkW3BRiw,301
5
+ gsas_query/app.py,sha256=xt9Sbia_DgmC3hiru5Xev7cxXmsIOcaqxLiHG6yfbWA,4476
6
+ gsas_query/cli.py,sha256=NuwCn6-IIRIbVMAeAmZEQpEtoEFqVTGzyEvOz5oUZAc,6244
7
+ gsas_query/gui.py,sha256=sYOmAPkXpbsffPg62zJfj-Zv2v9_OfJc9SnWLtJZxCY,25910
8
+ gsas_query/ingest.py,sha256=hEePnUW6luihqSMipv-7rTi-Qb4GfVECVlt3qrtUMfc,9664
9
+ gsas_query/rag.py,sha256=oq_y6U5CiAUg_Iwp3RCdUpK0Ess7i0yLFWw59Wueirw,14005
10
+ gsas_query/sources.py,sha256=I-TZonXza-TTkfKpY2y7mM40UW7VrNuE8HGgcYnyquQ,28418
11
+ gsas_query/wordcount.py,sha256=kCCvvoK6ad_GgsjmJQRTRpTqRncMXtJGNxN5POuUeZw,2366
12
+ gsas_query/static/index.html,sha256=xjfb15XUjlo8uVJV18meAUZr29Rol2-ShuLpedL0Cso,17697
13
+ gsas2_query-0.3.0.dist-info/METADATA,sha256=E2emtf0SeEX8KPF05vFIHKPfdGyvsBAk3h6oxRnleyo,10275
14
+ gsas2_query-0.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
15
+ gsas2_query-0.3.0.dist-info/entry_points.txt,sha256=2gcKy0n_QoJZH7wpgZi3PuSh_3R-UXit1ZmpG1V8QfY,91
16
+ gsas2_query-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ gsas2-query = gsas_query.cli:main
3
+ gsas2-query-web = gsas_query._web:main
gsas_query/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """
2
+ GSAS-II Documentation Assistant
3
+
4
+ Semantic search + AI answers over GSAS-II tutorials, help pages, and PDFs.
5
+ All embedding and retrieval runs locally; the LLM backend is configurable.
6
+
7
+ Quick start:
8
+ from gsas_query.rag import answer_question
9
+ result = answer_question("How do I set up a sequential refinement?", [])
10
+
11
+ wxPython GUI (for GSAS-II Help menu integration):
12
+ from gsas_query.gui import show_assistant
13
+ show_assistant(parent_wx_window)
14
+ """
15
+
16
+ from gsas_query._paths import get_chroma_path, get_data_dir, get_static_dir
17
+ from gsas_query.rag import answer_question
18
+
19
+ __all__ = ["answer_question", "get_chroma_path", "get_data_dir", "get_static_dir"]
20
+ __version__ = "0.3.0"
gsas_query/_embed.py ADDED
@@ -0,0 +1,43 @@
1
+ """Embedding model selection for Query-GSAS.
2
+
3
+ Default: BAAI/bge-base-en-v1.5 via fastembed (ONNX, no PyTorch required).
4
+ Override: set GSAS_QUERY_EMBED_MODEL=minilm to use all-MiniLM-L6-v2 instead.
5
+ """
6
+
7
+ import os
8
+ from functools import lru_cache
9
+
10
+ EMBED_MODEL_ENV = os.environ.get("GSAS_QUERY_EMBED_MODEL", "bge-base")
11
+
12
+ BGE_BASE_MODEL = "BAAI/bge-base-en-v1.5"
13
+
14
+
15
+ class _FastEmbedWrapper:
16
+ """ChromaDB-compatible wrapper around a fastembed TextEmbedding model."""
17
+
18
+ def __init__(self, model_name: str):
19
+ from fastembed import TextEmbedding # type: ignore
20
+ self._model = TextEmbedding(model_name)
21
+ self.model_name = model_name
22
+
23
+ def __call__(self, input: list[str]) -> list[list[float]]:
24
+ return [emb.tolist() for emb in self._model.embed(input)]
25
+
26
+
27
+ @lru_cache(maxsize=1)
28
+ def get_embedding_function():
29
+ """Return the configured embedding function (cached after first call)."""
30
+ if EMBED_MODEL_ENV == "minilm":
31
+ from chromadb.utils.embedding_functions import DefaultEmbeddingFunction
32
+ return DefaultEmbeddingFunction()
33
+
34
+ try:
35
+ return _FastEmbedWrapper(BGE_BASE_MODEL)
36
+ except ImportError:
37
+ print(
38
+ "Warning: fastembed is not installed. "
39
+ "Install with: pip install fastembed\n"
40
+ "Falling back to all-MiniLM-L6-v2 (lower retrieval quality)."
41
+ )
42
+ from chromadb.utils.embedding_functions import DefaultEmbeddingFunction
43
+ return DefaultEmbeddingFunction()
gsas_query/_paths.py ADDED
@@ -0,0 +1,25 @@
1
+ """Central path management for user data (chroma index) and package assets."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ # Suppress HuggingFace tokenizer fork warning and OpenMP threading —
7
+ # both must be set before sentence-transformers / loky are imported.
8
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
9
+ os.environ.setdefault("OMP_NUM_THREADS", "1")
10
+
11
+
12
+ def get_data_dir() -> Path:
13
+ """Return (and create) the user data directory for gsas_query."""
14
+ d = Path(os.environ.get("GSAS_QUERY_DATA_DIR",
15
+ Path.home() / ".GSASII" / "query_gsas2"))
16
+ d.mkdir(parents=True, exist_ok=True)
17
+ return d
18
+
19
+
20
+ def get_chroma_path() -> str:
21
+ return str(get_data_dir() / "chroma_db")
22
+
23
+
24
+ def get_static_dir() -> Path:
25
+ return Path(__file__).parent / "static"
gsas_query/_web.py ADDED
@@ -0,0 +1,16 @@
1
+ """Entry point for `gsas2-query-web` console script."""
2
+
3
+ import os
4
+
5
+
6
+ def main():
7
+ import uvicorn
8
+ from .app import app
9
+
10
+ host = os.environ.get("HOST", "0.0.0.0")
11
+ port = int(os.environ.get("PORT", "8000"))
12
+ uvicorn.run(app, host=host, port=port)
13
+
14
+
15
+ if __name__ == "__main__":
16
+ main()
gsas_query/app.py ADDED
@@ -0,0 +1,154 @@
1
+ """
2
+ FastAPI web server for the GSAS-II documentation chatbot.
3
+
4
+ GET / -> chat UI (static/index.html)
5
+ GET /health -> {"status": "ok"}
6
+ GET /stats -> {"chunks_indexed": N, "llm_backend": "..."}
7
+ POST /chat -> RAG query, returns {answer, sources, citations}
8
+ POST /ingest -> trigger re-indexing (requires X-Admin-Key header)
9
+ """
10
+
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from typing import Optional
16
+
17
+ from fastapi import Depends, FastAPI, HTTPException, Request
18
+ from fastapi.middleware.cors import CORSMiddleware
19
+ from fastapi.responses import FileResponse, JSONResponse
20
+ from pydantic import BaseModel, Field
21
+
22
+ from ._paths import get_static_dir
23
+
24
+ STATIC_DIR = get_static_dir()
25
+
26
+ app = FastAPI(
27
+ title="GSAS-II Documentation Assistant",
28
+ description="RAG chatbot over GSAS-II tutorials and help documentation",
29
+ version="0.2.0",
30
+ )
31
+
32
+ origins = os.environ.get("ALLOWED_ORIGINS", "*").split(",")
33
+ app.add_middleware(
34
+ CORSMiddleware,
35
+ allow_origins=origins,
36
+ allow_methods=["GET", "POST"],
37
+ allow_headers=["*"],
38
+ )
39
+
40
+
41
+ @app.exception_handler(Exception)
42
+ async def generic_exception_handler(request: Request, exc: Exception):
43
+ return JSONResponse(status_code=500, content={"detail": str(exc)})
44
+
45
+
46
+ # --------------------------------------------------------------------------- #
47
+ # Request / response models #
48
+ # --------------------------------------------------------------------------- #
49
+
50
+ class HistoryTurn(BaseModel):
51
+ role: str
52
+ content: str
53
+
54
+
55
+ class ChatRequest(BaseModel):
56
+ message: str = Field(..., max_length=2000)
57
+ history: list[HistoryTurn] = Field(default_factory=list, max_length=20)
58
+
59
+
60
+ class Source(BaseModel):
61
+ title: str
62
+ section: str
63
+ url: str
64
+ category: str
65
+ relevance: float
66
+
67
+
68
+ class Citation(BaseModel):
69
+ title: str
70
+ section: str
71
+ url: str
72
+ relevance: float = 0.0
73
+
74
+
75
+ class ChatResponse(BaseModel):
76
+ answer: str
77
+ sources: list[Source]
78
+ citations: dict[str, Citation]
79
+ elapsed_ms: int
80
+
81
+
82
+ # --------------------------------------------------------------------------- #
83
+ # Simple rate limiting (in-memory, per IP) #
84
+ # --------------------------------------------------------------------------- #
85
+
86
+ _rate_store: dict[str, list[float]] = {}
87
+ RATE_LIMIT = int(os.environ.get("RATE_LIMIT_RPM", "30"))
88
+
89
+
90
+ def check_rate_limit(request: Request):
91
+ if RATE_LIMIT <= 0:
92
+ return
93
+ ip = request.client.host if request.client else "unknown"
94
+ now = time.time()
95
+ timestamps = [t for t in _rate_store.get(ip, []) if now - t < 60]
96
+ if len(timestamps) >= RATE_LIMIT:
97
+ raise HTTPException(status_code=429, detail="Rate limit exceeded. Please slow down.")
98
+ timestamps.append(now)
99
+ _rate_store[ip] = timestamps
100
+
101
+
102
+ # --------------------------------------------------------------------------- #
103
+ # Routes #
104
+ # --------------------------------------------------------------------------- #
105
+
106
+ @app.get("/", include_in_schema=False)
107
+ async def serve_ui():
108
+ return FileResponse(str(STATIC_DIR / "index.html"))
109
+
110
+
111
+ @app.get("/health")
112
+ async def health():
113
+ return {"status": "ok"}
114
+
115
+
116
+ @app.get("/stats")
117
+ async def stats():
118
+ from .rag import _effective_backend, _get_collection
119
+ try:
120
+ col = _get_collection()
121
+ count = col.count()
122
+ except Exception:
123
+ count = -1
124
+ return {"chunks_indexed": count, "llm_backend": _effective_backend()}
125
+
126
+
127
+ @app.post("/chat", response_model=ChatResponse)
128
+ async def chat(
129
+ request: ChatRequest,
130
+ http_request: Request,
131
+ _: None = Depends(check_rate_limit),
132
+ ):
133
+ from .rag import answer_question
134
+
135
+ t0 = time.monotonic()
136
+ history = [h.model_dump() for h in request.history]
137
+ result = answer_question(request.message, history)
138
+ elapsed = int((time.monotonic() - t0) * 1000)
139
+
140
+ return ChatResponse(
141
+ answer=result["answer"],
142
+ sources=result["sources"],
143
+ citations=result.get("citations", {}),
144
+ elapsed_ms=elapsed,
145
+ )
146
+
147
+
148
+ @app.post("/ingest")
149
+ async def trigger_ingest(http_request: Request):
150
+ admin_key = os.environ.get("ADMIN_KEY", "")
151
+ if admin_key and http_request.headers.get("X-Admin-Key") != admin_key:
152
+ raise HTTPException(status_code=403, detail="Invalid admin key.")
153
+ subprocess.Popen([sys.executable, "-m", "gsas_query.ingest"])
154
+ return {"status": "ingestion started in background"}