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.
- gsas2_query-0.3.0.dist-info/METADATA +333 -0
- gsas2_query-0.3.0.dist-info/RECORD +16 -0
- gsas2_query-0.3.0.dist-info/WHEEL +4 -0
- gsas2_query-0.3.0.dist-info/entry_points.txt +3 -0
- gsas_query/__init__.py +20 -0
- gsas_query/_embed.py +43 -0
- gsas_query/_paths.py +25 -0
- gsas_query/_web.py +16 -0
- gsas_query/app.py +154 -0
- gsas_query/cli.py +215 -0
- gsas_query/gui.py +690 -0
- gsas_query/ingest.py +290 -0
- gsas_query/rag.py +390 -0
- gsas_query/sources.py +482 -0
- gsas_query/static/index.html +533 -0
- gsas_query/wordcount.py +78 -0
|
@@ -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,,
|
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"}
|