gsas-query 0.2.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.
- gsas_query/__init__.py +20 -0
- gsas_query/_paths.py +24 -0
- gsas_query/_web.py +16 -0
- gsas_query/app.py +154 -0
- gsas_query/cli.py +208 -0
- gsas_query/gui.py +310 -0
- gsas_query/ingest.py +228 -0
- gsas_query/rag.py +178 -0
- gsas_query/sources.py +361 -0
- gsas_query/static/index.html +506 -0
- gsas_query-0.2.0.dist-info/METADATA +283 -0
- gsas_query-0.2.0.dist-info/RECORD +14 -0
- gsas_query-0.2.0.dist-info/WHEEL +4 -0
- gsas_query-0.2.0.dist-info/entry_points.txt +3 -0
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.2.0"
|
gsas_query/_paths.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
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", Path.home() / ".gsas_query"))
|
|
15
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
return d
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_chroma_path() -> str:
|
|
20
|
+
return str(get_data_dir() / "chroma_db")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_static_dir() -> Path:
|
|
24
|
+
return Path(__file__).parent / "static"
|
gsas_query/_web.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Entry point for `gsas-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
|
+
|
|
73
|
+
|
|
74
|
+
class ChatResponse(BaseModel):
|
|
75
|
+
answer: str
|
|
76
|
+
sources: list[Source]
|
|
77
|
+
citations: dict[str, Citation]
|
|
78
|
+
elapsed_ms: int
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --------------------------------------------------------------------------- #
|
|
82
|
+
# Simple rate limiting (in-memory, per IP) #
|
|
83
|
+
# --------------------------------------------------------------------------- #
|
|
84
|
+
|
|
85
|
+
_rate_store: dict[str, list[float]] = {}
|
|
86
|
+
RATE_LIMIT = int(os.environ.get("RATE_LIMIT_RPM", "30"))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def check_rate_limit(request: Request):
|
|
90
|
+
if RATE_LIMIT <= 0:
|
|
91
|
+
return
|
|
92
|
+
ip = request.client.host if request.client else "unknown"
|
|
93
|
+
now = time.time()
|
|
94
|
+
timestamps = [t for t in _rate_store.get(ip, []) if now - t < 60]
|
|
95
|
+
if len(timestamps) >= RATE_LIMIT:
|
|
96
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded. Please slow down.")
|
|
97
|
+
timestamps.append(now)
|
|
98
|
+
_rate_store[ip] = timestamps
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# --------------------------------------------------------------------------- #
|
|
102
|
+
# Routes #
|
|
103
|
+
# --------------------------------------------------------------------------- #
|
|
104
|
+
|
|
105
|
+
@app.get("/", include_in_schema=False)
|
|
106
|
+
async def serve_ui():
|
|
107
|
+
return FileResponse(str(STATIC_DIR / "index.html"))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.get("/health")
|
|
111
|
+
async def health():
|
|
112
|
+
return {"status": "ok"}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.get("/stats")
|
|
116
|
+
async def stats():
|
|
117
|
+
from .rag import _get_collection
|
|
118
|
+
try:
|
|
119
|
+
col = _get_collection()
|
|
120
|
+
count = col.count()
|
|
121
|
+
except Exception:
|
|
122
|
+
count = -1
|
|
123
|
+
backend = os.environ.get("LLM_BACKEND", "ollama")
|
|
124
|
+
return {"chunks_indexed": count, "llm_backend": 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"}
|
gsas_query/cli.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GSAS-II Documentation Assistant — command-line interface.
|
|
3
|
+
|
|
4
|
+
First-time setup:
|
|
5
|
+
gsas-query --setup
|
|
6
|
+
gsas-query --setup --reset
|
|
7
|
+
gsas-query --setup --html-only
|
|
8
|
+
|
|
9
|
+
Ask a single question:
|
|
10
|
+
gsas-query "How do I set up a sequential refinement?"
|
|
11
|
+
|
|
12
|
+
Interactive REPL:
|
|
13
|
+
gsas-query
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import textwrap
|
|
19
|
+
|
|
20
|
+
from ._paths import get_chroma_path
|
|
21
|
+
|
|
22
|
+
WIDTH = 80
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _hr(char="─", width=WIDTH):
|
|
26
|
+
print(char * width)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _wrap(text: str, indent: int = 0) -> str:
|
|
30
|
+
prefix = " " * indent
|
|
31
|
+
return textwrap.fill(text, width=WIDTH, initial_indent=prefix, subsequent_indent=prefix)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _print_answer(result: dict):
|
|
35
|
+
answer = result.get("answer", "")
|
|
36
|
+
sources = result.get("sources", [])
|
|
37
|
+
|
|
38
|
+
print()
|
|
39
|
+
for para in answer.split("\n"):
|
|
40
|
+
if para.strip():
|
|
41
|
+
print(_wrap(para))
|
|
42
|
+
else:
|
|
43
|
+
print()
|
|
44
|
+
|
|
45
|
+
if sources:
|
|
46
|
+
print()
|
|
47
|
+
_hr("·")
|
|
48
|
+
print("Sources:")
|
|
49
|
+
seen = set()
|
|
50
|
+
for s in sources:
|
|
51
|
+
key = s["url"]
|
|
52
|
+
if key in seen:
|
|
53
|
+
continue
|
|
54
|
+
seen.add(key)
|
|
55
|
+
rel = int(s.get("relevance", 0) * 100)
|
|
56
|
+
label = f" [{rel}%] {s['title']}"
|
|
57
|
+
if s.get("section") and s["section"] != s["title"]:
|
|
58
|
+
label += f" › {s['section']}"
|
|
59
|
+
print(label)
|
|
60
|
+
print(f" {s['url']}")
|
|
61
|
+
print()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _collection_count() -> int:
|
|
65
|
+
try:
|
|
66
|
+
import chromadb
|
|
67
|
+
client = chromadb.PersistentClient(path=get_chroma_path())
|
|
68
|
+
return client.get_or_create_collection("gsasii_docs").count()
|
|
69
|
+
except Exception:
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run_setup(reset: bool = False, html_only: bool = False):
|
|
74
|
+
from . import ingest
|
|
75
|
+
import argparse
|
|
76
|
+
|
|
77
|
+
print("Starting ingestion. This may take 10–20 minutes.")
|
|
78
|
+
print(f"Index will be stored at: {get_chroma_path()}\n")
|
|
79
|
+
|
|
80
|
+
argv_backup = sys.argv
|
|
81
|
+
sys.argv = ["gsas-query"]
|
|
82
|
+
if reset:
|
|
83
|
+
sys.argv.append("--reset")
|
|
84
|
+
if html_only:
|
|
85
|
+
sys.argv.append("--html-only")
|
|
86
|
+
try:
|
|
87
|
+
ingest.main()
|
|
88
|
+
finally:
|
|
89
|
+
sys.argv = argv_backup
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ask(question: str, history: list | None = None) -> dict:
|
|
93
|
+
from .rag import answer_question
|
|
94
|
+
return answer_question(question, history or [])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def interactive():
|
|
98
|
+
count = _collection_count()
|
|
99
|
+
if count == 0:
|
|
100
|
+
print("\nWarning: the knowledge base is empty.")
|
|
101
|
+
print("Run: gsas-query --setup\n")
|
|
102
|
+
else:
|
|
103
|
+
print(f"\nKnowledge base: {count:,} indexed chunks.")
|
|
104
|
+
|
|
105
|
+
backend = os.environ.get("LLM_BACKEND", "ollama")
|
|
106
|
+
print(f"LLM backend: {backend}")
|
|
107
|
+
print("Type your question and press Enter. 'clear' resets history, 'quit' exits.\n")
|
|
108
|
+
_hr()
|
|
109
|
+
|
|
110
|
+
history: list[dict] = []
|
|
111
|
+
|
|
112
|
+
while True:
|
|
113
|
+
try:
|
|
114
|
+
question = input("You: ").strip()
|
|
115
|
+
except (EOFError, KeyboardInterrupt):
|
|
116
|
+
print("\nGoodbye.")
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
if not question:
|
|
120
|
+
continue
|
|
121
|
+
if question.lower() in {"quit", "exit", "q"}:
|
|
122
|
+
print("Goodbye.")
|
|
123
|
+
break
|
|
124
|
+
if question.lower() in {"clear", "reset"}:
|
|
125
|
+
history.clear()
|
|
126
|
+
print("(History cleared)\n")
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
print("Thinking…", end="", flush=True)
|
|
130
|
+
try:
|
|
131
|
+
result = ask(question, history)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
print(f"\rError: {e}\n")
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
print("\r" + " " * 12 + "\r", end="")
|
|
137
|
+
print("Assistant:", end="")
|
|
138
|
+
_print_answer(result)
|
|
139
|
+
|
|
140
|
+
history.append({"role": "user", "content": question})
|
|
141
|
+
history.append({"role": "assistant", "content": result.get("answer", "")})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main():
|
|
145
|
+
import argparse
|
|
146
|
+
|
|
147
|
+
parser = argparse.ArgumentParser(
|
|
148
|
+
description="GSAS-II Documentation Assistant",
|
|
149
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
150
|
+
epilog=__doc__,
|
|
151
|
+
)
|
|
152
|
+
parser.add_argument("question", nargs="?", help="Question to ask (omit for interactive mode)")
|
|
153
|
+
parser.add_argument("--setup", action="store_true", help="Index documentation (first-time setup)")
|
|
154
|
+
parser.add_argument("--reset", action="store_true", help="Drop and rebuild the index")
|
|
155
|
+
parser.add_argument("--html-only", action="store_true", help="Skip PDFs during setup")
|
|
156
|
+
parser.add_argument("--backend", choices=["ollama", "anthropic", "retrieval"],
|
|
157
|
+
help="Override LLM_BACKEND env var")
|
|
158
|
+
parser.add_argument("--model", help="Override OLLAMA_MODEL or ANTHROPIC_MODEL")
|
|
159
|
+
parser.add_argument("--stats", action="store_true", help="Show index statistics and exit")
|
|
160
|
+
parser.add_argument("--gui", action="store_true", help="Open the wxPython desktop assistant")
|
|
161
|
+
args = parser.parse_args()
|
|
162
|
+
|
|
163
|
+
if args.backend:
|
|
164
|
+
os.environ["LLM_BACKEND"] = args.backend
|
|
165
|
+
if args.model:
|
|
166
|
+
key = "OLLAMA_MODEL" if os.environ.get("LLM_BACKEND", "ollama") == "ollama" else "ANTHROPIC_MODEL"
|
|
167
|
+
os.environ[key] = args.model
|
|
168
|
+
|
|
169
|
+
print("GSAS-II Documentation Assistant")
|
|
170
|
+
_hr("═")
|
|
171
|
+
|
|
172
|
+
if args.stats:
|
|
173
|
+
count = _collection_count()
|
|
174
|
+
print(f"Indexed chunks : {count:,}")
|
|
175
|
+
print(f"LLM backend : {os.environ.get('LLM_BACKEND', 'ollama')}")
|
|
176
|
+
print(f"Chroma DB : {get_chroma_path()}")
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
if args.setup:
|
|
180
|
+
run_setup(reset=args.reset, html_only=args.html_only)
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
if args.gui:
|
|
184
|
+
from .gui import show_assistant
|
|
185
|
+
try:
|
|
186
|
+
import wx
|
|
187
|
+
except ImportError:
|
|
188
|
+
print("wxPython is required for the GUI. Install it or use GSAS-II's Python.")
|
|
189
|
+
sys.exit(1)
|
|
190
|
+
app = wx.App(False)
|
|
191
|
+
show_assistant()
|
|
192
|
+
app.MainLoop()
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
if args.question:
|
|
196
|
+
count = _collection_count()
|
|
197
|
+
if count == 0:
|
|
198
|
+
print("Knowledge base is empty. Run: gsas-query --setup")
|
|
199
|
+
sys.exit(1)
|
|
200
|
+
print(f"({count:,} chunks indexed)\n")
|
|
201
|
+
result = ask(args.question)
|
|
202
|
+
_print_answer(result)
|
|
203
|
+
else:
|
|
204
|
+
interactive()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
main()
|