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 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()