nikeshn commited on
Commit
e56edcc
·
verified ·
1 Parent(s): 131db7d

Upload 9 files

Browse files
Files changed (8) hide show
  1. .dockerignore +8 -0
  2. .env.example +11 -0
  3. .gitignore +7 -0
  4. Dockerfile +24 -11
  5. README.md +23 -62
  6. app.py +105 -204
  7. requirements-dev.txt +3 -0
  8. 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.9-slim
2
-
3
- WORKDIR /app
4
-
5
- COPY requirements.txt .
6
- RUN pip install --no-cache-dir -r requirements.txt
7
-
8
- COPY . .
9
-
10
- # HF Spaces requires port 7860
11
- CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # 🐝 LibBee — KU Library AI Assistant
12
 
13
- An open-source, institution-aware AI library assistant for Khalifa University Library, Abu Dhabi, UAE.
14
 
15
- Built with FastAPI, hybrid FAISS + BM25 RAG, and live LibCal integration.
16
 
17
- **Live interface:** https://ku-library.github.io/LibBee
18
- **GitHub:** https://github.com/ku-library/LibBee
 
 
 
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
- | Module | Role |
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
- `src/api/agent.py` is a thin router. Tests: `pytest tests/ -q` (20 tests, no
67
- API keys required).
68
 
69
- ## Environment variables (new in v3.8)
 
 
 
 
 
70
 
71
- | Variable | Purpose |
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
- ## Privacy
 
 
 
77
 
78
- Before any query reaches metrics or the D1 analytics store it passes
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 get_settings, LIBBEE_VERSION
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
- # ── Background Tasks ───────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
- async def _metrics_flush_loop(interval: int = 30) -> None:
73
- """Flush in-memory metric counters to disk every `interval` seconds."""
74
  while True:
75
- await asyncio.sleep(interval)
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(app: FastAPI):
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 — RAG disabled")
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 asyncio.wait_for(metrics_service.flush(), timeout=5.0)
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=_extra_origins + [
143
- "https://nikeshn.github.io",
144
- "https://ku-library.github.io", # added v3.5
145
- "https://library.ku.ac.ae", # LibGuides embed (added v3.11.1)
146
- "http://localhost:8080",
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 security_headers(request: Request, call_next):
161
- """
162
- Add security headers to every response.
163
- X-Content-Type-Options — prevents MIME sniffing
164
- X-Frame-Options — prevents clickjacking
165
- Referrer-Policy — limits referrer leakage
166
- X-XSS-Protection — legacy XSS filter (belt-and-braces)
167
- Permissions-Policy — disables unused browser features
168
- """
169
- response = await call_next(request)
170
- response.headers["X-Content-Type-Options"] = "nosniff"
171
- response.headers["X-Frame-Options"] = "SAMEORIGIN"
172
- response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
173
- response.headers["X-XSS-Protection"] = "1; mode=block"
174
- response.headers["Permissions-Policy"] = (
175
- "geolocation=(), microphone=(), camera=(), payment=()"
176
- )
177
- return response
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return JSONResponse(status_code=500, content={"detail": "Unexpected server error."})
241
-
 
 
 
 
 
 
 
 
 
 
242
 
243
- # ── Router Mounting ────────────────────────────────────────────────────────────
244
 
245
  from src.api import admin, agent, feedback, search # noqa: E402
246
 
247
- app.include_router(agent.router, prefix="/agent", tags=["Agent"])
248
- app.include_router(search.router, prefix="/search", tags=["Search"])
249
- app.include_router(admin.router, prefix="/admin", tags=["Admin"])
250
  app.include_router(feedback.router, prefix="/feedback", tags=["Feedback"])
251
 
252
 
253
- # ── Health & Public Endpoints ────────────────────────────────────────���─────────
254
-
255
- @app.get("/favicon.ico", include_in_schema=False)
256
- async def favicon():
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 health_check():
268
- settings = get_settings()
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": settings.maintenance_mode,
275
- "endpoints": ["/agent", "/search", "/admin", "/feedback"],
276
  }
277
 
278
 
279
  @app.get("/config")
 
280
  def public_config():
281
- """Legacy public config endpoint — frontend uses /admin/public-config instead."""
282
- settings = get_settings()
283
  return {
284
- "welcome_message": "Hi! I'm LibBee, the Khalifa University Library AI Assistant.",
285
- "max_results": settings.max_results,
286
- "maintenance_mode": settings.maintenance_mode,
 
287
  }
288
 
289
 
290
- @app.get("/year")
291
- def get_year():
292
- from datetime import datetime
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>=0.104.0
2
- uvicorn[standard]>=0.24.0
3
- python-multipart>=0.0.6
4
- pydantic>=2.5.0
5
- pydantic-settings>=2.1.0
6
- langchain>=0.1.0
7
- langchain-openai>=0.0.5
8
- langchain-anthropic>=0.1.0
9
- langchain-community>=0.0.10
10
- anthropic>=0.34.0
11
- faiss-cpu>=1.7.4
12
- rank-bm25>=0.2.2
13
- httpx>=0.25.0
14
- numpy>=1.26.0
15
- openai>=1.40.0
16
- cachetools>=5.3.0
 
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