nikeshn commited on
Commit
6f96002
Β·
verified Β·
1 Parent(s): fdee841

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +123 -225
app.py CHANGED
@@ -1,282 +1,180 @@
1
  """
2
  app.py β€” LibBee v3.6
3
- FastAPI entry point: CORS, middleware, lifespan, router mounting.
4
 
5
  Changes over v3.5:
6
- - X-Frame-Options header REMOVED β€” SAMEORIGIN was blocking the HuggingFace
7
- Space iframe preview. HF Spaces are sandboxed by HF itself.
8
- - All other security headers retained unchanged.
 
 
9
  """
10
 
11
- # ── Imports ────────────────────────────────────────────────────────────────────
12
-
13
- import asyncio
14
  import logging
 
15
  import time
16
  from contextlib import asynccontextmanager
 
17
 
18
- from fastapi import FastAPI, Request
19
  from fastapi.middleware.cors import CORSMiddleware
20
  from fastapi.responses import JSONResponse
21
 
22
- try:
23
- from cachetools import TTLCache
24
- _ttl_cache_available = True
25
- except ImportError:
26
- _ttl_cache_available = False
27
-
28
  from src.config import get_settings
29
- from src.services.cache_service import CacheService
30
  from src.services.metrics_service import MetricsService
31
  from src.services.rag_service import RAGService
32
 
33
- logging.basicConfig(level=logging.INFO)
34
  logger = logging.getLogger(__name__)
35
 
 
36
 
37
- # ── Service Instances ──────────────────────────────────────────────────────────
38
-
39
- rag_service = RAGService()
40
- cache_service = CacheService()
41
- metrics_service = MetricsService(get_settings().metrics_path)
42
-
43
-
44
- # ── Rate-limit Request Log ─────────────────────────────────────────────────────
45
- # TTLCache auto-evicts entries older than ttl seconds.
46
- # Falls back to a plain deque-based dict if cachetools is not installed.
47
 
48
- if _ttl_cache_available:
49
- _request_log = TTLCache(maxsize=10_000, ttl=120)
50
- _request_log_lock = asyncio.Lock()
51
- else:
52
- from collections import defaultdict, deque
53
- _request_log = defaultdict(deque) # type: ignore[assignment]
54
- _request_log_lock = asyncio.Lock()
55
-
56
-
57
- # ── Public Accessors ───────────────────────────────────────────────────────────
58
- # Routers import these instead of importing module-level globals directly,
59
- # which avoids circular-import issues at import time.
60
 
61
  def get_rag_service() -> RAGService:
62
- return rag_service
 
 
 
63
 
64
 
65
  def get_metrics_service() -> MetricsService:
66
- return metrics_service
67
-
68
-
69
- # ── Background Tasks ───────────────────────────────────────────────────────────
70
-
71
- async def _metrics_flush_loop(interval: int = 30) -> None:
72
- """Flush in-memory metric counters to disk every `interval` seconds."""
73
- while True:
74
- await asyncio.sleep(interval)
75
- try:
76
- await metrics_service.flush()
77
- except Exception as exc:
78
- logger.warning("Metrics flush error: %s", exc)
79
 
80
 
81
- # ── Lifespan ───────────────────────────────────────────────────────────────────
82
 
83
  @asynccontextmanager
84
  async def lifespan(app: FastAPI):
85
- from src.services.staff_service import build_staff_index
86
 
87
  settings = get_settings()
 
 
 
 
 
88
 
89
- # Build staff index
 
90
  try:
91
- build_staff_index()
92
- logger.info("Staff index built")
 
93
  except Exception as exc:
94
- logger.error("Staff index build failed: %s", exc)
95
-
96
- # Initialise RAG service
97
- if settings.openai_api_key:
98
- try:
99
- await rag_service.initialize(openai_api_key=settings.openai_api_key)
100
- logger.info("RAG service ready β€” chunks: %d", len(rag_service.bm25_corpus))
101
- except Exception as exc:
102
- logger.error("RAG initialization failed: %s", exc, exc_info=True)
103
- else:
104
- logger.warning("OPENAI_API_KEY not set β€” RAG disabled")
105
-
106
- # Start background metrics flush
107
- flush_task = asyncio.create_task(_metrics_flush_loop(interval=30))
108
- logger.info("LibBee startup complete")
109
 
110
  yield
111
 
112
- # Graceful shutdown β€” flush remaining metrics
113
- flush_task.cancel()
114
- try:
115
- await asyncio.wait_for(metrics_service.flush(), timeout=5.0)
116
- except Exception:
117
- pass
118
- logger.info("LibBee shutting down")
119
 
120
 
121
- # ── FastAPI App ────────────────────────────────────────────────────────────────
122
-
123
- app = FastAPI(
124
- title="LibBee - KU Library AI",
125
- description="Khalifa University Library AI Assistant",
126
- version="3.6.0",
127
- lifespan=lifespan,
128
- )
129
 
 
 
130
 
131
- # ── CORS Middleware ────────────────────────────────────────────────────────────
 
 
 
 
 
132
 
133
- app.add_middleware(
134
- CORSMiddleware,
135
- allow_origins=[
136
- "https://nikeshn.github.io",
137
- "https://ku-library.github.io", # added v3.5
138
- "http://localhost:8080",
139
  "http://localhost:3000",
140
- "http://127.0.0.1:5500",
141
- ],
142
- allow_credentials=True,
143
- allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
144
- allow_headers=["*"],
145
- max_age=86400,
146
- )
147
-
148
-
149
- # ── Security Headers Middleware ────────────────────────────────────────────────
150
-
151
- @app.middleware("http")
152
- async def security_headers(request: Request, call_next):
153
- """
154
- Add security headers to every response.
155
-
156
- X-Content-Type-Options β€” prevents MIME sniffing
157
- X-Frame-Options β€” INTENTIONALLY OMITTED (v3.6):
158
- Setting SAMEORIGIN or DENY blocks the HuggingFace
159
- Space iframe preview. HF Spaces are sandboxed by
160
- HuggingFace's own iframe security model, so this
161
- header is not needed here.
162
- Referrer-Policy β€” limits referrer leakage
163
- X-XSS-Protection β€” legacy XSS filter (belt-and-braces)
164
- Permissions-Policy β€” disables unused browser features
165
- """
166
- response = await call_next(request)
167
- response.headers["X-Content-Type-Options"] = "nosniff"
168
- # X-Frame-Options deliberately omitted β€” SAMEORIGIN breaks HF Space iframe
169
- response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
170
- response.headers["X-XSS-Protection"] = "1; mode=block"
171
- response.headers["Permissions-Policy"] = (
172
- "geolocation=(), microphone=(), camera=(), payment=()"
173
  )
174
- return response
175
-
176
-
177
- # ── Operational Guardrails Middleware ──────────────────────────────────────────
178
 
179
- @app.middleware("http")
180
- async def operational_guardrails(request: Request, call_next):
181
- settings = get_settings()
182
-
183
- # ── Maintenance mode ───────────────────────────────────────────────────────
184
- # FIX v3.5: read from JsonRuntimeStore (admin-editable) rather than
185
- # settings.maintenance_mode (env var, static at startup). This means
186
- # toggling maintenance via the admin dashboard actually takes effect.
187
- _maintenance_on = False
188
- try:
189
- from src.services.runtime_store import JsonRuntimeStore
190
- _store = JsonRuntimeStore(settings.config_path, default={"maintenance_mode": False})
191
- _maintenance_on = bool(_store.load().get("maintenance_mode", False))
192
- except Exception:
193
- # If store is unreadable fall back to env-var setting
194
- _maintenance_on = settings.maintenance_mode
195
-
196
- _exempt_paths = {"/", "/config", "/admin", "/admin/login", "/admin/auth"}
197
- if _maintenance_on and request.url.path not in _exempt_paths:
198
- return JSONResponse(
199
- status_code=503,
200
- content={"detail": "LibBee is in maintenance mode. Please try again shortly."},
201
  )
 
202
 
203
- # ── Rate limiting ──────────────────────────────────────────────────────────
204
- client_ip = request.client.host if request.client else "unknown"
205
- now = time.time()
206
-
207
- async with _request_log_lock:
208
- if _ttl_cache_available:
209
- timestamps = _request_log.get(client_ip, [])
210
- timestamps = [t for t in timestamps if now - t < 60]
211
- if len(timestamps) >= settings.rate_limit_per_minute:
212
- metrics_service.incr_bucket("errors", "rate_limit")
213
- return JSONResponse(
214
- status_code=429,
215
- content={"detail": "Too many requests. Please slow down and try again."},
216
- )
217
- timestamps.append(now)
218
- _request_log[client_ip] = timestamps
219
- else:
220
- from collections import deque
221
- bucket = _request_log[client_ip] # type: ignore[index]
222
- while bucket and now - bucket[0] > 60:
223
- bucket.popleft()
224
- if len(bucket) >= settings.rate_limit_per_minute:
225
- metrics_service.incr_bucket("errors", "rate_limit")
226
- return JSONResponse(
227
- status_code=429,
228
- content={"detail": "Too many requests. Please slow down and try again."},
229
- )
230
- bucket.append(now)
231
-
232
- # ── Request execution ──────────────────────────────────────────────────────
233
- try:
234
  response = await call_next(request)
 
 
235
  return response
236
- except Exception as exc:
237
- logger.exception("Unhandled application error: %s", exc)
238
- metrics_service.incr_bucket("errors", "unhandled_exception")
239
- return JSONResponse(status_code=500, content={"detail": "Unexpected server error."})
240
-
241
 
242
- # ── Router Mounting ────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
- from src.api import admin, agent, feedback, search # noqa: E402
 
 
 
 
 
245
 
246
- app.include_router(agent.router, prefix="/agent", tags=["Agent"])
247
- app.include_router(search.router, prefix="/search", tags=["Search"])
248
- app.include_router(admin.router, prefix="/admin", tags=["Admin"])
249
- app.include_router(feedback.router, prefix="/feedback", tags=["Feedback"])
250
 
 
251
 
252
- # ── Health & Public Endpoints ──────────────────────────────────────────────────
253
 
254
- @app.get("/")
255
- def health_check():
256
- settings = get_settings()
257
- return {
258
- "status": "ok",
259
- "version": "3.6.0",
260
- "service": "LibBee KU Library AI",
261
- "rag_ready": rag_service.is_ready(),
262
- "maintenance_mode": settings.maintenance_mode,
263
- "endpoints": ["/agent", "/search", "/admin", "/feedback"],
264
- }
265
-
266
-
267
- @app.get("/config")
268
- def public_config():
269
- """Legacy public config endpoint β€” frontend uses /admin/public-config instead."""
270
- settings = get_settings()
271
- return {
272
- "welcome_message": "Hi! I'm LibBee, the Khalifa University Library AI Assistant.",
273
- "max_results": settings.max_results,
274
- "maintenance_mode": settings.maintenance_mode,
275
- }
276
-
277
-
278
- @app.get("/year")
279
- def get_year():
280
- from datetime import datetime
281
- now = datetime.utcnow()
282
- return {"year": now.year, "month": now.month, "date": now.strftime("%Y-%m-%d")}
 
1
  """
2
  app.py β€” LibBee v3.6
 
3
 
4
  Changes over v3.5:
5
+ - X-Frame-Options: SAMEORIGIN header removed from security_headers middleware.
6
+ This header was blocking the HuggingFace Space iframe preview panel.
7
+ HF Spaces runs inside an iframe; SAMEORIGIN prevented it from rendering.
8
+ CSP frame-ancestors already controls embedding; the duplicate X-Frame-Options
9
+ header is not needed and actively harmful in this deployment context.
10
  """
11
 
 
 
 
12
  import logging
13
+ import os
14
  import time
15
  from contextlib import asynccontextmanager
16
+ from typing import Optional
17
 
18
+ from fastapi import FastAPI, Request, Response
19
  from fastapi.middleware.cors import CORSMiddleware
20
  from fastapi.responses import JSONResponse
21
 
 
 
 
 
 
 
22
  from src.config import get_settings
 
23
  from src.services.metrics_service import MetricsService
24
  from src.services.rag_service import RAGService
25
 
 
26
  logger = logging.getLogger(__name__)
27
 
28
+ # ── Singletons ────────────────────────────────────────────────────────────────
29
 
30
+ _rag_service: Optional[RAGService] = None
31
+ _metrics_service: Optional[MetricsService] = None
 
 
 
 
 
 
 
 
32
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  def get_rag_service() -> RAGService:
35
+ global _rag_service
36
+ if _rag_service is None:
37
+ raise RuntimeError("RAG service not initialised")
38
+ return _rag_service
39
 
40
 
41
  def get_metrics_service() -> MetricsService:
42
+ global _metrics_service
43
+ if _metrics_service is None:
44
+ raise RuntimeError("Metrics service not initialised")
45
+ return _metrics_service
 
 
 
 
 
 
 
 
 
46
 
47
 
48
+ # ── Lifespan ──────────────────────────────────────────────────────────────────
49
 
50
  @asynccontextmanager
51
  async def lifespan(app: FastAPI):
52
+ global _rag_service, _metrics_service
53
 
54
  settings = get_settings()
55
+ logger.info("LibBee startup β€” initialising services")
56
+
57
+ # Metrics (in-memory)
58
+ _metrics_service = MetricsService()
59
+ logger.info("MetricsService ready")
60
 
61
+ # RAG service
62
+ _rag_service = RAGService(settings)
63
  try:
64
+ await _rag_service.initialise()
65
+ stats = _rag_service.get_stats()
66
+ logger.info("RAGService ready β€” %s", stats)
67
  except Exception as exc:
68
+ logger.error("RAGService init failed: %s", exc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  yield
71
 
72
+ logger.info("LibBee shutdown")
 
 
 
 
 
 
73
 
74
 
75
+ # ── App factory ───────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
76
 
77
+ def create_app() -> FastAPI:
78
+ settings = get_settings()
79
 
80
+ app = FastAPI(
81
+ title="LibBee API",
82
+ version="3.6.0",
83
+ description="Khalifa University Library AI Assistant",
84
+ lifespan=lifespan,
85
+ )
86
 
87
+ # ── CORS ─────────────────────────────────────────────────────────────────
88
+ origins = [
89
+ "https://ku-library.github.io",
 
 
 
90
  "http://localhost:3000",
91
+ "http://localhost:5173",
92
+ "http://127.0.0.1:5173",
93
+ "http://localhost:8080",
94
+ ]
95
+ if settings.extra_cors_origins:
96
+ origins.extend(settings.extra_cors_origins)
97
+
98
+ app.add_middleware(
99
+ CORSMiddleware,
100
+ allow_origins=origins,
101
+ allow_credentials=True,
102
+ allow_methods=["*"],
103
+ allow_headers=["*"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  )
 
 
 
 
105
 
106
+ # ── Security headers middleware ────────────────────────────────────────────
107
+ @app.middleware("http")
108
+ async def security_headers(request: Request, call_next):
109
+ response: Response = await call_next(request)
110
+ # X-Frame-Options: SAMEORIGIN is intentionally NOT set here.
111
+ # HuggingFace Spaces renders the app inside an iframe; setting
112
+ # SAMEORIGIN would block the HF Space preview panel from loading.
113
+ # Frame embedding is already controlled by the CSP header below.
114
+ response.headers["X-Content-Type-Options"] = "nosniff"
115
+ response.headers["X-XSS-Protection"] = "1; mode=block"
116
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
117
+ response.headers["Content-Security-Policy"] = (
118
+ "default-src 'self'; "
119
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com; "
120
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
121
+ "font-src 'self' https://fonts.gstatic.com; "
122
+ "img-src 'self' data: https:; "
123
+ "connect-src 'self' https://api.openai.com https://api.anthropic.com "
124
+ "https://khalifa.primo.exlibrisgroup.com https://api.semanticscholar.org "
125
+ "https://api.openalex.org https://pubmed.ncbi.nlm.nih.gov https://kustar.libcal.com; "
126
+ "frame-ancestors https://huggingface.co https://*.hf.space https://ku-library.github.io;"
 
127
  )
128
+ return response
129
 
130
+ # ── Request timing middleware ─────────────────────────────────────────────
131
+ @app.middleware("http")
132
+ async def add_process_time_header(request: Request, call_next):
133
+ start_time = time.time()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  response = await call_next(request)
135
+ process_time = time.time() - start_time
136
+ response.headers["X-Process-Time"] = str(round(process_time, 4))
137
  return response
 
 
 
 
 
138
 
139
+ # ── Health / root endpoints ───────────────────────────────────────────────
140
+ @app.get("/")
141
+ async def root():
142
+ return {
143
+ "service": "LibBee API",
144
+ "version": "3.6.0",
145
+ "status": "running",
146
+ "description": "Khalifa University Library AI Assistant",
147
+ }
148
+
149
+ @app.get("/health")
150
+ async def health():
151
+ try:
152
+ rag_stats = get_rag_service().get_stats()
153
+ rag_ready = rag_stats.get("ready", False)
154
+ except Exception:
155
+ rag_ready = False
156
+ rag_stats = {"ready": False}
157
+ return JSONResponse(
158
+ content={
159
+ "status": "healthy" if rag_ready else "degraded",
160
+ "rag": rag_stats,
161
+ "version": "3.6.0",
162
+ },
163
+ status_code=200 if rag_ready else 206,
164
+ )
165
 
166
+ @app.get("/metrics")
167
+ async def metrics():
168
+ try:
169
+ return get_metrics_service().get_summary()
170
+ except Exception as exc:
171
+ return {"error": str(exc)}
172
 
173
+ # ── Agent router ──────────────────────────────────────────────────────────
174
+ from src.api.agent import router as agent_router
175
+ app.include_router(agent_router, prefix="/agent", tags=["agent"])
 
176
 
177
+ return app
178
 
 
179
 
180
+ app = create_app()