Upload 9 files
Browse files- .dockerignore +8 -0
- .env.example +11 -0
- .gitignore +7 -0
- Dockerfile +24 -11
- README.md +23 -62
- app.py +105 -204
- requirements-dev.txt +3 -0
- requirements.txt +16 -16
.dockerignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.pytest_cache
|
| 3 |
+
__pycache__
|
| 4 |
+
*.py[cod]
|
| 5 |
+
.env
|
| 6 |
+
.venv
|
| 7 |
+
tests
|
| 8 |
+
data/knowledge_raw_archive
|
.env.example
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ENVIRONMENT=development
|
| 2 |
+
ORIGIN_TOKEN=replace-with-the-same-random-value-as-worker-HF_ORIGIN_TOKEN
|
| 3 |
+
OPENAI_API_KEY=
|
| 4 |
+
ANTHROPIC_API_KEY=
|
| 5 |
+
PRIMO_API_KEY=
|
| 6 |
+
ADMIN_PASSWORD=replace-with-a-strong-admin-password
|
| 7 |
+
CLOUDFLARE_WORKER_URL=https://REPLACE-WITH-YOUR-WORKER.workers.dev
|
| 8 |
+
CLOUDFLARE_ADMIN_TOKEN=replace-with-the-same-random-value-as-worker-ADMIN_TOKEN
|
| 9 |
+
CONTACT_EMAIL=libse@ku.ac.ae
|
| 10 |
+
RUNTIME_DIR=./libbee_runtime
|
| 11 |
+
ALLOWED_CORS_ORIGINS=http://localhost:8080,http://127.0.0.1:5500
|
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.venv/
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.py[cod]
|
| 6 |
+
data/rag_cache/
|
| 7 |
+
libbee_runtime/
|
Dockerfile
CHANGED
|
@@ -1,11 +1,24 @@
|
|
| 1 |
-
FROM python:3.
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PIP_NO_CACHE_DIR=1
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
RUN addgroup --system libbee \
|
| 10 |
+
&& adduser --system --ingroup libbee --home /app libbee \
|
| 11 |
+
&& mkdir -p /data/libbee_runtime \
|
| 12 |
+
&& chown -R libbee:libbee /app /data/libbee_runtime
|
| 13 |
+
|
| 14 |
+
COPY --chown=libbee:libbee requirements.txt ./
|
| 15 |
+
RUN pip install --no-cache-dir --requirement requirements.txt
|
| 16 |
+
|
| 17 |
+
COPY --chown=libbee:libbee . .
|
| 18 |
+
USER libbee
|
| 19 |
+
|
| 20 |
+
EXPOSE 7860
|
| 21 |
+
HEALTHCHECK --interval=30s --timeout=8s --start-period=90s --retries=3 \
|
| 22 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/health/live', timeout=5)"
|
| 23 |
+
|
| 24 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
README.md
CHANGED
|
@@ -8,75 +8,36 @@ app_file: app.py
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
-
#
|
| 12 |
|
| 13 |
-
|
| 14 |
|
| 15 |
-
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
## Architecture
|
| 23 |
-
|
| 24 |
-
- **Backend:** FastAPI + Python 3.9 (this Space)
|
| 25 |
-
- **Frontend:** GitHub Pages (single-file HTML/JS)
|
| 26 |
-
- **RAG:** FAISS dense + BM25 sparse + RRF fusion
|
| 27 |
-
- **Live data:** LibCal API via Cloudflare Worker proxy
|
| 28 |
-
- **Analytics:** Cloudflare D1
|
| 29 |
-
|
| 30 |
-
## Secrets required
|
| 31 |
-
|
| 32 |
-
Set these in the Space **Settings → Variables and Secrets**:
|
| 33 |
-
|
| 34 |
-
| Secret | Description |
|
| 35 |
-
|---|---|
|
| 36 |
-
| `OPENAI_API_KEY` | Required for classification and answering |
|
| 37 |
-
| `ANTHROPIC_API_KEY` | Optional — enables Claude Haiku toggle |
|
| 38 |
-
| `PRIMO_API_KEY` | Required for PRIMO discovery search |
|
| 39 |
-
| `ADMIN_PASSWORD` | Required for /admin dashboard |
|
| 40 |
-
| `CLOUDFLARE_WORKER_URL` | Required for live hours/events and analytics |
|
| 41 |
-
| `SESSION_SECRET` | Required for admin session |
|
| 42 |
-
|
| 43 |
-
## Version
|
| 44 |
-
|
| 45 |
-
**v3.7** — Navigation-first agentic AI. See GitHub for changelog.
|
| 46 |
-
|
| 47 |
-
## Architecture (v3.8)
|
| 48 |
-
|
| 49 |
-
The pipeline is split by stage under `src/agentcore/` - each module's docstring
|
| 50 |
-
explains its role:
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|---|---|
|
| 54 |
-
| `models.py` | Pydantic I/O contracts |
|
| 55 |
-
| `constants.py` | Regexes, URLs, prompts (no logic) |
|
| 56 |
-
| `utils.py` | Shared helpers, LLM factory, query/URL builders |
|
| 57 |
-
| `classify.py` | Rule pre-classifier + LLM intent classifier |
|
| 58 |
-
| `libcal.py` | Live hours/events via Cloudflare Worker proxy |
|
| 59 |
-
| `scholarly.py` | OpenAlex, Semantic Scholar, Crossref, Unpaywall clients |
|
| 60 |
-
| `rendering.py` | HTML fragments (badges, trace blocks, AI-tools footer) |
|
| 61 |
-
| `intents_library.py` | library_info handlers |
|
| 62 |
-
| `intents_search.py` | search_academic / search_medical machinery |
|
| 63 |
-
| `intents_general.py` | general / social handlers |
|
| 64 |
-
| `orchestrator.py` | Injection gate, routing pipeline, analytics logging |
|
| 65 |
|
| 66 |
-
|
| 67 |
-
API keys required).
|
| 68 |
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|---|---|
|
| 73 |
-
| `CLOUDFLARE_WORKER_TOKEN` | Bearer token for the Worker analytics surface; must equal the Worker's `ANALYTICS_TOKEN` secret |
|
| 74 |
-
| `CONTACT_EMAIL` | Polite-pool contact sent to OpenAlex / Unpaywall / Crossref (default `library@ku.ac.ae`) |
|
| 75 |
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
-
|
| 79 |
-
`_redact_for_analytics`: e-mail addresses and digit runs of 6+ are masked and
|
| 80 |
-
text is truncated to 200 characters. Answer excerpts logged for the daily
|
| 81 |
-
relevance agent get the same treatment. The Worker applies the identical
|
| 82 |
-
redaction server-side as defence in depth.
|
|
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# LibBee private origin v4.0
|
| 12 |
|
| 13 |
+
This Hugging Face Space is the private application origin behind the Cloudflare Worker. Browsers must not call `/agent`, `/search`, or `/feedback` directly. Those routes require `X-LibBee-Origin-Token`, which the Worker adds server-side.
|
| 14 |
|
| 15 |
+
## Required Space secrets
|
| 16 |
|
| 17 |
+
- `ORIGIN_TOKEN`: a new random secret; must match the Worker's `HF_ORIGIN_TOKEN`.
|
| 18 |
+
- `OPENAI_API_KEY`: required for the default model, classification, embeddings, and RAG.
|
| 19 |
+
- `ADMIN_PASSWORD`: strong password for `/admin`.
|
| 20 |
+
- `CLOUDFLARE_WORKER_URL`: the deployed Worker URL or custom domain; used for live LibCal data and the server-side analytics proxy.
|
| 21 |
+
- `CLOUDFLARE_ADMIN_TOKEN`: must match the Worker's `ADMIN_TOKEN`; never sent to browser JavaScript.
|
| 22 |
|
| 23 |
+
Optional secrets: `ANTHROPIC_API_KEY` and `PRIMO_API_KEY`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
Use persistent HF storage and keep `RUNTIME_DIR=/data/libbee_runtime` so runtime configuration, metrics, feedback fallback data, and the signed RAG cache survive restarts.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
## Security properties
|
|
|
|
| 28 |
|
| 29 |
+
- Protected origin routes fail closed if `ORIGIN_TOKEN` is absent or incorrect.
|
| 30 |
+
- Request models have strict size and enum limits.
|
| 31 |
+
- Answer HTML is sanitized with a tag and attribute allow-list before it leaves the origin; the LibGuides client sanitizes it again.
|
| 32 |
+
- The signed FAISS/BM25 cache is ignored if it was modified or if no origin secret is configured.
|
| 33 |
+
- Raw scrape dumps are kept in `data/knowledge_raw_archive` but excluded from indexing. Curated, review-dated files are indexed.
|
| 34 |
+
- Persistent analytics is written by the Worker, not by the browser or the HF origin.
|
| 35 |
|
| 36 |
+
## Local checks
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
+
```bash
|
| 39 |
+
python -m compileall -q app.py src tests
|
| 40 |
+
pytest -q
|
| 41 |
+
```
|
| 42 |
|
| 43 |
+
See the complete package `DEPLOYMENT_GUIDE.md` for coordinated HF, Worker, D1, and LibGuides deployment.
|
|
|
|
|
|
|
|
|
|
|
|
app.py
CHANGED
|
@@ -1,64 +1,26 @@
|
|
| 1 |
-
"""
|
| 2 |
-
app.py — LibBee v3.5
|
| 3 |
-
FastAPI entry point: CORS, middleware, lifespan, router mounting.
|
| 4 |
-
|
| 5 |
-
Changes over v3.1:
|
| 6 |
-
- Maintenance mode middleware now reads from JsonRuntimeStore (admin-editable)
|
| 7 |
-
instead of settings.maintenance_mode (env var, static at startup).
|
| 8 |
-
- CORS origins updated: added ku-library.github.io.
|
| 9 |
-
- Section headers added throughout.
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
# ── Imports ────────────────────────────────────────────────────────────────────
|
| 13 |
|
| 14 |
import asyncio
|
|
|
|
| 15 |
import logging
|
| 16 |
-
import time
|
| 17 |
from contextlib import asynccontextmanager
|
| 18 |
|
| 19 |
from fastapi import FastAPI, Request
|
| 20 |
from fastapi.middleware.cors import CORSMiddleware
|
| 21 |
-
from fastapi.responses import JSONResponse
|
| 22 |
-
|
| 23 |
-
try:
|
| 24 |
-
from cachetools import TTLCache
|
| 25 |
-
_ttl_cache_available = True
|
| 26 |
-
except ImportError:
|
| 27 |
-
_ttl_cache_available = False
|
| 28 |
|
| 29 |
-
from src.config import
|
| 30 |
-
from src.services.cache_service import CacheService
|
| 31 |
from src.services.metrics_service import MetricsService
|
| 32 |
from src.services.rag_service import RAGService
|
| 33 |
|
| 34 |
logging.basicConfig(level=logging.INFO)
|
| 35 |
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
-
# ── Service Instances ──────────────────────────────────────────────────────────
|
| 39 |
-
|
| 40 |
-
rag_service = RAGService()
|
| 41 |
-
cache_service = CacheService()
|
| 42 |
-
metrics_service = MetricsService(get_settings().metrics_path)
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
# ── Rate-limit Request Log ─────────────────────────────────────────────────────
|
| 46 |
-
# TTLCache auto-evicts entries older than ttl seconds.
|
| 47 |
-
# Falls back to a plain deque-based dict if cachetools is not installed.
|
| 48 |
-
|
| 49 |
-
if _ttl_cache_available:
|
| 50 |
-
_request_log = TTLCache(maxsize=10_000, ttl=120)
|
| 51 |
-
_request_log_lock = asyncio.Lock()
|
| 52 |
-
else:
|
| 53 |
-
from collections import defaultdict, deque
|
| 54 |
-
_request_log = defaultdict(deque) # type: ignore[assignment]
|
| 55 |
-
_request_log_lock = asyncio.Lock()
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
# ── Public Accessors ───────────────────────────────────────────────────────────
|
| 59 |
-
# Routers import these instead of importing module-level globals directly,
|
| 60 |
-
# which avoids circular-import issues at import time.
|
| 61 |
-
|
| 62 |
def get_rag_service() -> RAGService:
|
| 63 |
return rag_service
|
| 64 |
|
|
@@ -67,228 +29,167 @@ def get_metrics_service() -> MetricsService:
|
|
| 67 |
return metrics_service
|
| 68 |
|
| 69 |
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
-
async def _metrics_flush_loop(
|
| 73 |
-
"""Flush in-memory metric counters to disk every `interval` seconds."""
|
| 74 |
while True:
|
| 75 |
-
await asyncio.sleep(
|
| 76 |
try:
|
| 77 |
await metrics_service.flush()
|
| 78 |
except Exception as exc:
|
| 79 |
logger.warning("Metrics flush error: %s", exc)
|
| 80 |
|
| 81 |
|
| 82 |
-
# ── Lifespan ───────────────────────────────────────────────────────────────────
|
| 83 |
-
|
| 84 |
@asynccontextmanager
|
| 85 |
-
async def lifespan(
|
| 86 |
from src.services.staff_service import build_staff_index
|
| 87 |
|
| 88 |
-
settings = get_settings()
|
| 89 |
-
|
| 90 |
-
# Build staff index
|
| 91 |
try:
|
| 92 |
build_staff_index()
|
| 93 |
-
logger.info("Staff index built")
|
| 94 |
except Exception as exc:
|
| 95 |
logger.error("Staff index build failed: %s", exc)
|
| 96 |
-
|
| 97 |
-
# Initialise RAG service
|
| 98 |
if settings.openai_api_key:
|
| 99 |
try:
|
| 100 |
await rag_service.initialize(openai_api_key=settings.openai_api_key)
|
| 101 |
-
logger.info("RAG service ready — chunks: %d", len(rag_service.bm25_corpus))
|
| 102 |
except Exception as exc:
|
| 103 |
logger.error("RAG initialization failed: %s", exc, exc_info=True)
|
| 104 |
else:
|
| 105 |
-
logger.warning("OPENAI_API_KEY not set
|
| 106 |
-
|
| 107 |
-
# Start background metrics flush
|
| 108 |
-
flush_task = asyncio.create_task(_metrics_flush_loop(interval=30))
|
| 109 |
-
logger.info("LibBee startup complete")
|
| 110 |
-
|
| 111 |
yield
|
| 112 |
-
|
| 113 |
-
# Graceful shutdown — flush remaining metrics
|
| 114 |
flush_task.cancel()
|
| 115 |
try:
|
| 116 |
-
await
|
| 117 |
except Exception:
|
| 118 |
pass
|
| 119 |
-
logger.info("LibBee shutting down")
|
| 120 |
|
| 121 |
|
| 122 |
-
# ── FastAPI App ────────────────────────────────────────────────────────────────
|
| 123 |
-
|
| 124 |
app = FastAPI(
|
| 125 |
title="LibBee - KU Library AI",
|
| 126 |
-
description="Khalifa University Library AI Assistant",
|
| 127 |
version=LIBBEE_VERSION,
|
| 128 |
lifespan=lifespan,
|
|
|
|
|
|
|
| 129 |
)
|
| 130 |
|
| 131 |
-
|
| 132 |
-
# ── CORS Middleware ────────────────────────────────────────────────────────────
|
| 133 |
-
|
| 134 |
-
_extra_origins = [
|
| 135 |
-
o.strip().rstrip("/")
|
| 136 |
-
for o in (get_settings().extra_cors_origins or "").split(",")
|
| 137 |
-
if o.strip()
|
| 138 |
-
]
|
| 139 |
-
|
| 140 |
app.add_middleware(
|
| 141 |
CORSMiddleware,
|
| 142 |
-
allow_origins=
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
"http://localhost:3000",
|
| 148 |
-
"http://127.0.0.1:5500",
|
| 149 |
-
],
|
| 150 |
-
allow_credentials=True,
|
| 151 |
-
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
| 152 |
-
allow_headers=["*"],
|
| 153 |
-
max_age=86400,
|
| 154 |
)
|
| 155 |
|
| 156 |
|
| 157 |
-
# ── Operational Guardrails Middleware ──────────────────────────────────────────
|
| 158 |
-
|
| 159 |
@app.middleware("http")
|
| 160 |
-
async def
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
-
|
| 180 |
-
@app.middleware("http")
|
| 181 |
-
async def operational_guardrails(request: Request, call_next):
|
| 182 |
-
settings = get_settings()
|
| 183 |
-
|
| 184 |
-
# ── Maintenance mode ───────────────────────────────────────────────────────
|
| 185 |
-
# FIX v3.5: read from JsonRuntimeStore (admin-editable) rather than
|
| 186 |
-
# settings.maintenance_mode (env var, static at startup). This means
|
| 187 |
-
# toggling maintenance via the admin dashboard actually takes effect.
|
| 188 |
-
_maintenance_on = False
|
| 189 |
-
try:
|
| 190 |
-
from src.services.runtime_store import JsonRuntimeStore
|
| 191 |
-
_store = JsonRuntimeStore(settings.config_path, default={"maintenance_mode": False})
|
| 192 |
-
_maintenance_on = bool(_store.load().get("maintenance_mode", False))
|
| 193 |
-
except Exception:
|
| 194 |
-
# If store is unreadable fall back to env-var setting
|
| 195 |
-
_maintenance_on = settings.maintenance_mode
|
| 196 |
-
|
| 197 |
-
_exempt_paths = {"/", "/config", "/admin", "/admin/login", "/admin/auth"}
|
| 198 |
-
if _maintenance_on and request.url.path not in _exempt_paths:
|
| 199 |
-
return JSONResponse(
|
| 200 |
-
status_code=503,
|
| 201 |
-
content={"detail": "LibBee is in maintenance mode. Please try again shortly."},
|
| 202 |
-
)
|
| 203 |
-
|
| 204 |
-
# ── Rate limiting ──────────────────────────────────────────────────────────
|
| 205 |
-
client_ip = request.client.host if request.client else "unknown"
|
| 206 |
-
now = time.time()
|
| 207 |
-
|
| 208 |
-
async with _request_log_lock:
|
| 209 |
-
if _ttl_cache_available:
|
| 210 |
-
timestamps = _request_log.get(client_ip, [])
|
| 211 |
-
timestamps = [t for t in timestamps if now - t < 60]
|
| 212 |
-
if len(timestamps) >= settings.rate_limit_per_minute:
|
| 213 |
-
metrics_service.incr_bucket("errors", "rate_limit")
|
| 214 |
-
return JSONResponse(
|
| 215 |
-
status_code=429,
|
| 216 |
-
content={"detail": "Too many requests. Please slow down and try again."},
|
| 217 |
-
)
|
| 218 |
-
timestamps.append(now)
|
| 219 |
-
_request_log[client_ip] = timestamps
|
| 220 |
-
else:
|
| 221 |
-
from collections import deque
|
| 222 |
-
bucket = _request_log[client_ip] # type: ignore[index]
|
| 223 |
-
while bucket and now - bucket[0] > 60:
|
| 224 |
-
bucket.popleft()
|
| 225 |
-
if len(bucket) >= settings.rate_limit_per_minute:
|
| 226 |
-
metrics_service.incr_bucket("errors", "rate_limit")
|
| 227 |
-
return JSONResponse(
|
| 228 |
-
status_code=429,
|
| 229 |
-
content={"detail": "Too many requests. Please slow down and try again."},
|
| 230 |
-
)
|
| 231 |
-
bucket.append(now)
|
| 232 |
-
|
| 233 |
-
# ── Request execution ──────────────────────────────────────────────────────
|
| 234 |
try:
|
| 235 |
response = await call_next(request)
|
| 236 |
-
return response
|
| 237 |
except Exception as exc:
|
| 238 |
logger.exception("Unhandled application error: %s", exc)
|
| 239 |
metrics_service.incr_bucket("errors", "unhandled_exception")
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
-
# ── Router Mounting ────────────────────────────────────────────────────────────
|
| 244 |
|
| 245 |
from src.api import admin, agent, feedback, search # noqa: E402
|
| 246 |
|
| 247 |
-
app.include_router(agent.router,
|
| 248 |
-
app.include_router(search.router,
|
| 249 |
-
app.include_router(admin.router,
|
| 250 |
app.include_router(feedback.router, prefix="/feedback", tags=["Feedback"])
|
| 251 |
|
| 252 |
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
"""
|
| 258 |
-
Return 204 No Content for favicon requests.
|
| 259 |
-
Prevents 404 errors in browser console — the backend has no favicon,
|
| 260 |
-
the frontend (GitHub Pages) serves its own at assets/libbee-mascot.png.
|
| 261 |
-
"""
|
| 262 |
-
from fastapi.responses import Response
|
| 263 |
-
return Response(status_code=204)
|
| 264 |
|
| 265 |
|
| 266 |
-
@app.get("/")
|
| 267 |
-
def
|
| 268 |
-
|
| 269 |
return {
|
| 270 |
-
"status": "ok",
|
| 271 |
"version": LIBBEE_VERSION,
|
| 272 |
-
"service": "LibBee KU Library AI",
|
| 273 |
"rag_ready": rag_service.is_ready(),
|
| 274 |
-
"maintenance_mode":
|
| 275 |
-
"endpoints": ["/agent", "/search", "/admin", "/feedback"],
|
| 276 |
}
|
| 277 |
|
| 278 |
|
| 279 |
@app.get("/config")
|
|
|
|
| 280 |
def public_config():
|
| 281 |
-
|
| 282 |
-
settings = get_settings()
|
| 283 |
return {
|
| 284 |
-
"welcome_message": "
|
| 285 |
-
"
|
| 286 |
-
"maintenance_mode":
|
|
|
|
| 287 |
}
|
| 288 |
|
| 289 |
|
| 290 |
-
@app.get("/
|
| 291 |
-
def
|
| 292 |
-
|
| 293 |
-
now = datetime.utcnow()
|
| 294 |
-
return {"year": now.year, "month": now.month, "date": now.strftime("%Y-%m-%d")}
|
|
|
|
| 1 |
+
"""LibBee FastAPI application for the private Hugging Face origin."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
+
import hmac
|
| 5 |
import logging
|
|
|
|
| 6 |
from contextlib import asynccontextmanager
|
| 7 |
|
| 8 |
from fastapi import FastAPI, Request
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from fastapi.responses import JSONResponse, Response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
+
from src.config import LIBBEE_VERSION, get_settings
|
|
|
|
| 13 |
from src.services.metrics_service import MetricsService
|
| 14 |
from src.services.rag_service import RAGService
|
| 15 |
|
| 16 |
logging.basicConfig(level=logging.INFO)
|
| 17 |
logger = logging.getLogger(__name__)
|
| 18 |
+
settings = get_settings()
|
| 19 |
+
rag_service = RAGService()
|
| 20 |
+
metrics_service = MetricsService(settings.metrics_path)
|
| 21 |
+
_agent_slots = asyncio.Semaphore(settings.max_concurrent_agent_requests)
|
| 22 |
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
def get_rag_service() -> RAGService:
|
| 25 |
return rag_service
|
| 26 |
|
|
|
|
| 29 |
return metrics_service
|
| 30 |
|
| 31 |
|
| 32 |
+
def _runtime_config() -> dict:
|
| 33 |
+
from src.services.runtime_store import JsonRuntimeStore
|
| 34 |
+
|
| 35 |
+
defaults = {
|
| 36 |
+
"maintenance_mode": settings.maintenance_mode,
|
| 37 |
+
"maintenance_message": settings.maintenance_message,
|
| 38 |
+
"welcome_message": "Hi! I'm LibBee, the Khalifa University Library AI Assistant.",
|
| 39 |
+
"announcement": "",
|
| 40 |
+
"max_results": settings.max_results,
|
| 41 |
+
}
|
| 42 |
+
try:
|
| 43 |
+
return JsonRuntimeStore(settings.config_path, default=defaults).load()
|
| 44 |
+
except Exception:
|
| 45 |
+
return defaults
|
| 46 |
+
|
| 47 |
|
| 48 |
+
async def _metrics_flush_loop() -> None:
|
|
|
|
| 49 |
while True:
|
| 50 |
+
await asyncio.sleep(30)
|
| 51 |
try:
|
| 52 |
await metrics_service.flush()
|
| 53 |
except Exception as exc:
|
| 54 |
logger.warning("Metrics flush error: %s", exc)
|
| 55 |
|
| 56 |
|
|
|
|
|
|
|
| 57 |
@asynccontextmanager
|
| 58 |
+
async def lifespan(_app: FastAPI):
|
| 59 |
from src.services.staff_service import build_staff_index
|
| 60 |
|
|
|
|
|
|
|
|
|
|
| 61 |
try:
|
| 62 |
build_staff_index()
|
|
|
|
| 63 |
except Exception as exc:
|
| 64 |
logger.error("Staff index build failed: %s", exc)
|
|
|
|
|
|
|
| 65 |
if settings.openai_api_key:
|
| 66 |
try:
|
| 67 |
await rag_service.initialize(openai_api_key=settings.openai_api_key)
|
|
|
|
| 68 |
except Exception as exc:
|
| 69 |
logger.error("RAG initialization failed: %s", exc, exc_info=True)
|
| 70 |
else:
|
| 71 |
+
logger.warning("OPENAI_API_KEY not set; RAG is disabled")
|
| 72 |
+
flush_task = asyncio.create_task(_metrics_flush_loop())
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
yield
|
|
|
|
|
|
|
| 74 |
flush_task.cancel()
|
| 75 |
try:
|
| 76 |
+
await metrics_service.flush()
|
| 77 |
except Exception:
|
| 78 |
pass
|
|
|
|
| 79 |
|
| 80 |
|
|
|
|
|
|
|
| 81 |
app = FastAPI(
|
| 82 |
title="LibBee - KU Library AI",
|
| 83 |
+
description="Private origin API for the Khalifa University Library AI Assistant",
|
| 84 |
version=LIBBEE_VERSION,
|
| 85 |
lifespan=lifespan,
|
| 86 |
+
docs_url=None if settings.environment == "production" else "/docs",
|
| 87 |
+
redoc_url=None,
|
| 88 |
)
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
app.add_middleware(
|
| 91 |
CORSMiddleware,
|
| 92 |
+
allow_origins=settings.cors_origins,
|
| 93 |
+
allow_credentials=False,
|
| 94 |
+
allow_methods=["GET", "POST", "OPTIONS"],
|
| 95 |
+
allow_headers=["Content-Type", "X-LibBee-Origin-Token", "X-Request-ID", "X-LibBee-Canary"],
|
| 96 |
+
max_age=3600,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
)
|
| 98 |
|
| 99 |
|
|
|
|
|
|
|
| 100 |
@app.middleware("http")
|
| 101 |
+
async def secure_origin(request: Request, call_next):
|
| 102 |
+
path = request.url.path
|
| 103 |
+
protected = path in {"/agent", "/agent/rag-status", "/search", "/feedback"}
|
| 104 |
+
acquired = False
|
| 105 |
+
|
| 106 |
+
if path.startswith("/admin") and request.method not in {"GET", "HEAD", "OPTIONS"}:
|
| 107 |
+
fetch_site = request.headers.get("sec-fetch-site", "")
|
| 108 |
+
if fetch_site and fetch_site not in {"same-origin", "none"}:
|
| 109 |
+
return JSONResponse(status_code=403, content={"detail": "Cross-site admin request denied."})
|
| 110 |
+
|
| 111 |
+
if request.method in {"POST", "PUT", "PATCH"}:
|
| 112 |
+
length = request.headers.get("content-length")
|
| 113 |
+
if length and length.isdigit() and int(length) > 32_768:
|
| 114 |
+
return JSONResponse(status_code=413, content={"detail": "Request body too large."})
|
| 115 |
+
|
| 116 |
+
if protected:
|
| 117 |
+
supplied = request.headers.get("x-libbee-origin-token", "")
|
| 118 |
+
configured = settings.origin_token
|
| 119 |
+
local_insecure = settings.environment in {"development", "test"} and not configured
|
| 120 |
+
if not local_insecure and (not configured or not hmac.compare_digest(supplied, configured)):
|
| 121 |
+
return JSONResponse(status_code=401, content={"detail": "Origin authentication required."})
|
| 122 |
+
|
| 123 |
+
runtime = _runtime_config()
|
| 124 |
+
if runtime.get("maintenance_mode") and request.headers.get("x-libbee-canary") != "1":
|
| 125 |
+
return JSONResponse(
|
| 126 |
+
status_code=503,
|
| 127 |
+
content={"detail": str(runtime.get("maintenance_message") or settings.maintenance_message)[:300]},
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
if path == "/agent":
|
| 131 |
+
try:
|
| 132 |
+
await asyncio.wait_for(_agent_slots.acquire(), timeout=0.25)
|
| 133 |
+
acquired = True
|
| 134 |
+
except TimeoutError:
|
| 135 |
+
return JSONResponse(status_code=503, content={"detail": "LibBee is busy. Please retry shortly."})
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
try:
|
| 138 |
response = await call_next(request)
|
|
|
|
| 139 |
except Exception as exc:
|
| 140 |
logger.exception("Unhandled application error: %s", exc)
|
| 141 |
metrics_service.incr_bucket("errors", "unhandled_exception")
|
| 142 |
+
response = JSONResponse(status_code=500, content={"detail": "Unexpected server error."})
|
| 143 |
+
finally:
|
| 144 |
+
if acquired:
|
| 145 |
+
_agent_slots.release()
|
| 146 |
+
|
| 147 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 148 |
+
response.headers["Referrer-Policy"] = "no-referrer"
|
| 149 |
+
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=(), payment=()"
|
| 150 |
+
response.headers["X-Frame-Options"] = "SAMEORIGIN" if path.startswith("/admin") else "DENY"
|
| 151 |
+
if protected or path.startswith("/admin"):
|
| 152 |
+
response.headers["Cache-Control"] = "no-store"
|
| 153 |
+
return response
|
| 154 |
|
|
|
|
| 155 |
|
| 156 |
from src.api import admin, agent, feedback, search # noqa: E402
|
| 157 |
|
| 158 |
+
app.include_router(agent.router, prefix="/agent", tags=["Agent"])
|
| 159 |
+
app.include_router(search.router, prefix="/search", tags=["Search"])
|
| 160 |
+
app.include_router(admin.router, prefix="/admin", tags=["Admin"])
|
| 161 |
app.include_router(feedback.router, prefix="/feedback", tags=["Feedback"])
|
| 162 |
|
| 163 |
|
| 164 |
+
@app.get("/")
|
| 165 |
+
@app.get("/health/live")
|
| 166 |
+
def health_live():
|
| 167 |
+
return {"status": "ok", "version": LIBBEE_VERSION, "service": "LibBee private origin"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
|
| 170 |
+
@app.get("/health/ready")
|
| 171 |
+
def health_ready():
|
| 172 |
+
runtime = _runtime_config()
|
| 173 |
return {
|
| 174 |
+
"status": "ok" if not runtime.get("maintenance_mode") else "maintenance",
|
| 175 |
"version": LIBBEE_VERSION,
|
|
|
|
| 176 |
"rag_ready": rag_service.is_ready(),
|
| 177 |
+
"maintenance_mode": bool(runtime.get("maintenance_mode")),
|
|
|
|
| 178 |
}
|
| 179 |
|
| 180 |
|
| 181 |
@app.get("/config")
|
| 182 |
+
@app.get("/admin/public-config")
|
| 183 |
def public_config():
|
| 184 |
+
runtime = _runtime_config()
|
|
|
|
| 185 |
return {
|
| 186 |
+
"welcome_message": str(runtime.get("welcome_message", ""))[:500],
|
| 187 |
+
"announcement": str(runtime.get("announcement", ""))[:500],
|
| 188 |
+
"maintenance_mode": bool(runtime.get("maintenance_mode")),
|
| 189 |
+
"maintenance_message": str(runtime.get("maintenance_message", ""))[:300],
|
| 190 |
}
|
| 191 |
|
| 192 |
|
| 193 |
+
@app.get("/favicon.ico", include_in_schema=False)
|
| 194 |
+
def favicon():
|
| 195 |
+
return Response(status_code=204)
|
|
|
|
|
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
pytest==8.3.5
|
| 3 |
+
ruff==0.11.8
|
requirements.txt
CHANGED
|
@@ -1,16 +1,16 @@
|
|
| 1 |
-
fastapi
|
| 2 |
-
uvicorn[standard]
|
| 3 |
-
python-multipart
|
| 4 |
-
pydantic
|
| 5 |
-
pydantic-settings
|
| 6 |
-
langchain
|
| 7 |
-
langchain-openai
|
| 8 |
-
langchain-anthropic
|
| 9 |
-
langchain-community
|
| 10 |
-
anthropic
|
| 11 |
-
faiss-cpu
|
| 12 |
-
rank-bm25
|
| 13 |
-
httpx
|
| 14 |
-
numpy
|
| 15 |
-
openai
|
| 16 |
-
cachetools
|
|
|
|
| 1 |
+
fastapi==0.115.12
|
| 2 |
+
uvicorn[standard]==0.34.2
|
| 3 |
+
python-multipart==0.0.20
|
| 4 |
+
pydantic==2.11.4
|
| 5 |
+
pydantic-settings==2.9.1
|
| 6 |
+
langchain==0.3.25
|
| 7 |
+
langchain-openai==0.3.16
|
| 8 |
+
langchain-anthropic==0.3.13
|
| 9 |
+
langchain-community==0.3.24
|
| 10 |
+
anthropic==0.51.0
|
| 11 |
+
faiss-cpu==1.11.0
|
| 12 |
+
rank-bm25==0.2.2
|
| 13 |
+
httpx==0.28.1
|
| 14 |
+
numpy==2.2.5
|
| 15 |
+
openai==1.79.0
|
| 16 |
+
cachetools==5.5.2
|