nikeshn commited on
Commit
c8738a6
·
verified ·
1 Parent(s): d15c5ee

Upload 3 files

Browse files
Files changed (2) hide show
  1. Dockerfile +6 -2
  2. app.py +237 -430
Dockerfile CHANGED
@@ -1,7 +1,11 @@
1
- FROM python:3.11-slim
 
2
  WORKDIR /app
 
3
  COPY requirements.txt .
4
  RUN pip install --no-cache-dir -r requirements.txt
 
5
  COPY . .
6
- EXPOSE 7860
 
7
  CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
 
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"]
app.py CHANGED
@@ -1,448 +1,255 @@
1
  """
2
- Research Navigator — FastAPI backend for HuggingFace Spaces
3
- Endpoints: /api/analyze-journal, /api/check-license, /api/find-repository, /api/health
 
 
 
 
 
 
4
  """
5
- import os, json, re, asyncio
6
- from fastapi import FastAPI, HTTPException
 
 
 
 
 
 
 
7
  from fastapi.middleware.cors import CORSMiddleware
8
- from fastapi.staticfiles import StaticFiles
9
- from fastapi.responses import FileResponse, Response
10
- from pydantic import BaseModel
11
- from typing import Optional
12
- from openai import AsyncOpenAI
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
- from scimago_index import lookup_any, load_scimago, search_by_subject, load_subject_index
15
- from openalex import enrich_all
16
- from prompts import JOURNAL_SYSTEM_PROMPT, LICENSE_SYSTEM_PROMPT, REPO_SYSTEM_PROMPT, SUBJECT_NORMALISE_PROMPT
17
 
18
- # ── App setup ────────��─────────────────────────────────────────────────────
19
- app = FastAPI(title="Research Navigator API", version="1.0.0")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  app.add_middleware(
22
  CORSMiddleware,
23
- allow_origins=["*"],
24
- allow_methods=["*"],
 
 
 
 
 
 
 
25
  allow_headers=["*"],
 
26
  )
27
 
28
- # Preload SCImago at startup
29
- @app.on_event("startup")
30
- async def startup():
31
- load_scimago()
32
- load_subject_index()
33
-
34
- # ── OpenAI client ──────────────────────────────────────────────────────────
35
- def get_client():
36
- key = os.environ.get("OPENAI_API_KEY")
37
- if not key:
38
- raise HTTPException(500, "OPENAI_API_KEY not configured")
39
- return AsyncOpenAI(api_key=key)
40
-
41
- # ── Constants ──────────────────────────────────────────────────────────────
42
- KHAZNA = {
43
- "name": "Khazna — Khalifa University Research Portal",
44
- "type": "Institutional",
45
- "url": "https://khazna.ku.ac.ae",
46
- "scope": "Khalifa University institutional repository for articles, theses, datasets and research output.",
47
- "fit_reason": "Primary institutional repository for all KU researchers. Deposit here for KU compliance and visibility.",
48
- "data_types_accepted": "All types — articles, datasets, theses, preprints, reports",
49
- "max_file_size": "Contact library for large datasets",
50
- "licences_supported": "CC BY, CC BY-NC, CC BY-NC-ND, Restricted",
51
- "persistent_identifier": "Handle",
52
- "versioning": "Yes",
53
- "embargo_support": "Yes",
54
- "access_model": "Mixed",
55
- "cost": "Free",
56
- "certification": "Institutional",
57
- "fair_alignment": "High",
58
- "funder_compliance_note": "Satisfies KU institutional OA and RDM policy requirements.",
59
- "sensitive_data_suitable": "Conditional",
60
- "confidence": "High",
61
- "verification_status": "Confirmed — KU institutional repository",
62
- "risk_flag": None,
63
- "is_khazna": True,
64
- "verify_links": {
65
- "re3data": "https://www.re3data.org/search?query=Khalifa+University",
66
- "fairsharing": "https://fairsharing.org/search?q=Khalifa+University",
67
- "khazna_contact": "mailto:khazna@ku.ac.ae"
68
- }
69
- }
70
-
71
- KHAZNA_DEPOSIT_CARD = {
72
- "title": "🏛️ Deposit to Khazna — KU Institutional Repository",
73
- "url": "https://khazna.ku.ac.ae",
74
- "contact": "khazna@ku.ac.ae",
75
- "library_url": "https://library.ku.ac.ae/lib",
76
- "message": "As a KU researcher, deposit your accepted manuscript (or metadata record if under embargo) to Khazna for institutional visibility and compliance.",
77
- "metadata_tip": "Even if depositing data in a domain-specific repository, always register metadata in Khazna so your work appears in KU's research portfolio."
78
- }
79
-
80
- def _norm_issn(raw):
81
- if not raw: return None
82
- d = str(raw).strip().upper().replace(" ","").replace("-","")
83
- if len(d) == 8: return f"{d[:4]}-{d[4:]}"
84
- return None
85
-
86
- def build_verify_links(name: str, issn: Optional[str]) -> dict:
87
- q = re.sub(r'[^\w\s]', '', name)
88
- ni = _norm_issn(issn)
89
- iq = ni or q
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  return {
91
- "sherpa_romeo": f"https://openpolicyfinder.jisc.ac.uk/search?query={iq}",
92
- "scopus": f"https://www.scopus.com/sources?search={iq}",
93
- "scimago": f"https://www.scimagojr.com/journalsearch.php?q={iq}&tip={'iss' if ni else 'jou'}",
94
- "doaj": f"https://doaj.org/toc/{ni}" if ni else f"https://doaj.org/search/journals?source=%7B%22query%22%3A%7B%22query_string%22%3A%7B%22query%22%3A%22{q}%22%7D%7D%7D",
95
- "openalex": f"https://api.openalex.org/sources/issn:{ni}" if ni else f"https://api.openalex.org/sources?search={q}",
 
96
  }
97
 
98
- def enrich_ranking(journal: dict, scimago: Optional[dict], oa_pct: Optional[dict]) -> dict:
99
- """Build ranking block from SCImago (private) + OpenAlex percentile."""
100
- if scimago:
101
- q = scimago.get("quartile", "")
102
- return {
103
- "requested": True,
104
- "source": "Verified from journal data",
105
- "year": "2025",
106
- "category": scimago.get("categories", ""),
107
- "metric_value": None, # Never expose SJR value
108
- "percentile": f"Rank #{scimago.get('rank','—')} globally" if scimago.get("rank") else None,
109
- "quartile": q if q and q != "-" else None,
110
- "interpretation": f"Quartile verified from journal metadata (2025 dataset, matched by ISSN {scimago.get('matched_issn','')}).",
111
- "verification_status": "Confirmed",
112
- "h_index": scimago.get("h_index"),
113
- }
114
- elif oa_pct:
115
- return {
116
- "requested": True,
117
- "source": "OpenAlex-derived percentile",
118
- "year": "2025",
119
- "category": oa_pct.get("concept"),
120
- "metric_value": None,
121
- "percentile": f"{oa_pct['percentile']}th percentile within {oa_pct['peer_count']} {oa_pct['concept']} journals",
122
- "quartile": oa_pct.get("quartile"),
123
- "interpretation": f"OpenAlex-derived percentile — NOT a Scopus/SCImago quartile. Computed from 2-year mean citedness vs peer journals in the same concept field.",
124
- "verification_status": "OpenAlex-derived (not a Scopus/SCImago metric)",
125
- }
126
- else:
127
- return {
128
- "requested": True,
129
- "source": "Not available",
130
- "year": None, "category": None, "metric_value": None,
131
- "percentile": None, "quartile": None,
132
- "interpretation": "No journal metrics match found. Use verify links to check Scopus Sources directly.",
133
- "verification_status": "Not confirmed — verify manually via Scopus Sources link",
134
- }
135
-
136
- async def enrich_journals(journals: list) -> list:
137
- """Add SCImago quartile, OpenAlex metrics, ranking, verify links to each journal."""
138
- enriched_oa = await enrich_all(journals)
139
- result = []
140
- for j in enriched_oa:
141
- issn = j.get("issn")
142
- oa = j.get("openalex") or {}
143
- all_issns = [issn, oa.get("issn_l")] + (oa.get("issns") or [])
144
- scimago = lookup_any(all_issns)
145
- oa_pct = j.get("oa_percentile")
146
- # Final ISSN: prefer scimago matched, then openalex issn_l, then original
147
- final_issn = (scimago or {}).get("matched_issn") or oa.get("issn_l") or issn
148
- j["issn"] = final_issn
149
- j["ranking"] = enrich_ranking(j, scimago, oa_pct)
150
- j["verify_links"] = build_verify_links(j.get("name",""), final_issn)
151
- # Clean up internal fields
152
- j.pop("oa_percentile", None)
153
- result.append(j)
154
- return result
155
-
156
- def enrich_extended(extended: list) -> list:
157
- """Add verify links + SCImago quartile to extended list items."""
158
- index_result = []
159
- for j in extended:
160
- issn = j.get("issn")
161
- scimago = lookup_any([issn]) if issn else None
162
- q = (scimago or {}).get("quartile", "")
163
- j["quartile"] = q if q and q != "-" else None
164
- j["verify_links"] = build_verify_links(j.get("name",""), issn)
165
- index_result.append(j)
166
- return index_result
167
-
168
- async def call_openai(client, model: str, system: str, user: str) -> dict:
169
- resp = await client.chat.completions.create(
170
- model=model,
171
- response_format={"type": "json_object"},
172
- messages=[
173
- {"role": "system", "content": system},
174
- {"role": "user", "content": user}
175
- ],
176
- max_tokens=4096,
177
- temperature=0.3,
178
- )
179
- content = resp.choices[0].message.content or "{}"
180
- try:
181
- return json.loads(content)
182
- except json.JSONDecodeError:
183
- m = re.search(r'\{[\s\S]*\}', content)
184
- return json.loads(m.group(0)) if m else {"error": "Failed to parse response"}
185
-
186
- # ── Request models ─────────────────────────────────────────────────────────
187
- class ManuscriptInput(BaseModel):
188
- title: str
189
- abstract: str
190
- keywords: str = ""
191
- discipline: str = ""
192
- article_type: str = "Original research article"
193
- methods: str = ""
194
- audience: str = ""
195
- funder: str = ""
196
- institution: str = ""
197
- country: str = ""
198
- apc_budget: str = ""
199
- oa_preference: str = "Either"
200
- ranking_preference: str = "No filter"
201
- ranking_source: str = "Either"
202
- speed_preference: str = "Standard"
203
- preferred_journals: str = ""
204
- avoid_journals: str = ""
205
- repository_target: str = "Institutional repository"
206
-
207
- class JournalRequest(BaseModel):
208
- manuscript: ManuscriptInput
209
- model: str = "gpt-4o-mini"
210
-
211
- class LicenseInput(BaseModel):
212
- journal_name: str
213
- issn: str = ""
214
- publisher: str = ""
215
- manuscript_version: str = "All versions"
216
- funder: str = ""
217
- intended_repository: str = "Institutional repository"
218
- intended_licence: str = "CC BY"
219
- notes: str = ""
220
-
221
- class LicenseRequest(BaseModel):
222
- license_input: LicenseInput
223
- model: str = "gpt-4o-mini"
224
-
225
- class DatasetInput(BaseModel):
226
- title: str
227
- description: str
228
- discipline: str
229
- data_types: str
230
- file_formats: str = ""
231
- approx_size: str = ""
232
- sensitivity: str = "Open"
233
- contains_personal_data: str = "No"
234
- licence_intent: str = "CC BY"
235
- funder: str = ""
236
- institution: str = ""
237
- country: str = ""
238
- needs_doi: str = "Yes"
239
- needs_versioning: str = "Yes"
240
- embargo_required: str = "No"
241
- preferred_repository: str = ""
242
- related_publication: str = ""
243
- notes: str = ""
244
-
245
- class RepoRequest(BaseModel):
246
- dataset: DatasetInput
247
- model: str = "gpt-4o-mini"
248
-
249
- # ── Routes ─────────────────────────────────────────────────────────────────
250
- @app.get("/api/health")
251
- async def health():
252
- return {"status": "ok", "service": "Research Navigator"}
253
-
254
- @app.post("/api/analyze-journal")
255
- async def analyze_journal(req: JournalRequest):
256
- model = req.model if req.model in ("gpt-4o-mini", "gpt-4o") else "gpt-4o-mini"
257
- client = get_client()
258
- user_msg = f"""Analyse this manuscript and return the structured JSON response.
259
-
260
- MANUSCRIPT INPUT:
261
- {json.dumps(req.manuscript.dict(), indent=2)}
262
-
263
- Return JSON only — no prose outside the JSON object."""
264
-
265
- parsed = await call_openai(client, model, JOURNAL_SYSTEM_PROMPT, user_msg)
266
- if "error" in parsed:
267
- raise HTTPException(500, parsed["error"])
268
-
269
- # Enrich detailed journals
270
- if isinstance(parsed.get("journals"), list):
271
- parsed["journals"] = await enrich_journals(parsed["journals"])
272
-
273
- # Enrich extended list (lightweight — just quartile + verify links)
274
- if isinstance(parsed.get("extended_list"), list):
275
- parsed["extended_list"] = enrich_extended(parsed["extended_list"])
276
-
277
- # Always inject Khazna deposit card
278
- parsed["khazna"] = KHAZNA_DEPOSIT_CARD
279
-
280
- return {"result": parsed}
281
-
282
- @app.post("/api/check-license")
283
- async def check_license(req: LicenseRequest):
284
- model = req.model if req.model in ("gpt-4o-mini", "gpt-4o") else "gpt-4o-mini"
285
- client = get_client()
286
- li = req.license_input
287
- user_msg = f"""Check the Green OA / self-archiving policy for this journal. Return JSON only.
288
-
289
- JOURNAL DETAILS:
290
- - Journal name: {li.journal_name}
291
- - ISSN: {li.issn or 'Not provided'}
292
- - Publisher: {li.publisher or 'Not provided'}
293
- - Manuscript version of interest: {li.manuscript_version}
294
- - Funder: {li.funder or 'None'}
295
- - Intended repository: {li.intended_repository}
296
- - Intended licence: {li.intended_licence}
297
- - Additional notes: {li.notes or 'None'}"""
298
-
299
- parsed = await call_openai(client, model, LICENSE_SYSTEM_PROMPT, user_msg)
300
- if "error" in parsed:
301
- raise HTTPException(500, parsed["error"])
302
-
303
- if isinstance(parsed.get("journals"), list):
304
- parsed["journals"] = await enrich_journals(parsed["journals"])
305
-
306
- parsed["khazna"] = KHAZNA_DEPOSIT_CARD
307
- return {"result": parsed}
308
-
309
- @app.post("/api/find-repository")
310
- async def find_repository(req: RepoRequest):
311
- model = req.model if req.model in ("gpt-4o-mini", "gpt-4o") else "gpt-4o-mini"
312
- client = get_client()
313
- user_msg = f"""Recommend research data repositories for this dataset. Return JSON only.
314
-
315
- DATASET INPUT:
316
- {json.dumps(req.dataset.dict(), indent=2)}"""
317
-
318
- parsed = await call_openai(client, model, REPO_SYSTEM_PROMPT, user_msg)
319
- if "error" in parsed:
320
- raise HTTPException(500, parsed["error"])
321
-
322
- # Ensure Khazna is always first in repositories list
323
- repos = parsed.get("repositories", [])
324
- has_khazna = any(r.get("is_khazna") or "khazna" in r.get("url","").lower() for r in repos)
325
- if not has_khazna:
326
- repos.insert(0, KHAZNA)
327
- else:
328
- repos = [r for r in repos if not (r.get("is_khazna") or "khazna" in r.get("url","").lower())]
329
- repos.insert(0, KHAZNA)
330
- # Add verify links to all non-Khazna repos
331
- for r in repos:
332
- if not r.get("is_khazna"):
333
- clean_name = re.sub(r'[^\w\s]', '', r.get('name', ''))
334
- r["verify_links"] = {
335
- "re3data": f"https://www.re3data.org/search?query={clean_name}",
336
- "fairsharing": f"https://fairsharing.org/search?q={clean_name}",
337
- }
338
- parsed["repositories"] = repos
339
- return {"result": parsed}
340
-
341
- # ── Static frontend ────────────────────────────────────────────────────────
342
-
343
- # ── Subject browse request model ───────────────────────────────────────────
344
- class SubjectRequest(BaseModel):
345
- subject: str
346
- model: str = "gpt-4o-mini"
347
-
348
- @app.post("/api/browse-subject")
349
- async def browse_subject(req: SubjectRequest):
350
- """
351
- NEW endpoint: normalise user subject via GPT, search SCImago CSV,
352
- return top 15 journals with quartile + verify links.
353
- Does NOT affect any existing endpoint.
354
- """
355
- if not req.subject or not req.subject.strip():
356
- raise HTTPException(400, "Subject cannot be empty")
357
-
358
- model = req.model if req.model in ("gpt-4o-mini", "gpt-4o") else "gpt-4o-mini"
359
- client = get_client()
360
-
361
- # Step 1 — GPT normalises the subject
362
- parsed = await call_openai(
363
- client, model,
364
- SUBJECT_NORMALISE_PROMPT,
365
- f"Subject input: {req.subject.strip()}"
366
- )
367
-
368
- confidence = parsed.get("confidence", "not_found")
369
- normalised = parsed.get("normalised", req.subject)
370
- category = parsed.get("scimago_category", "")
371
- area = parsed.get("scimago_area", "")
372
- user_message = parsed.get("user_message")
373
-
374
- # Step 2 — if not_found, return early
375
- if confidence == "not_found":
376
- return {
377
- "result": {
378
- "confidence": "not_found",
379
- "normalised": normalised,
380
- "user_message": user_message or f"Could not identify a valid academic subject for '{req.subject}'. Try terms like: Computer Science, Medicine, Engineering, Physics.",
381
- "journals": []
382
- }
383
- }
384
-
385
- # Step 3 — search SCImago CSV
386
- journals = search_by_subject(category, area, top_n=15)
387
-
388
- # Step 4 — if no CSV match, try broader area search
389
- fallback_used = False
390
- if not journals and area:
391
- journals = search_by_subject("", area, top_n=15)
392
- fallback_used = True
393
-
394
- if not journals:
395
- return {
396
- "result": {
397
- "confidence": "not_found",
398
- "normalised": normalised,
399
- "user_message": f"No journals found for '{normalised}' in our database. Try a broader term.",
400
- "journals": []
401
- }
402
- }
403
-
404
- # Step 5 — enrich each journal with verify links
405
- enriched = []
406
- for j in journals:
407
- issn = j.get("issn")
408
- vl = build_verify_links(j["title"], issn)
409
- q = j.get("quartile", "")
410
- enriched.append({
411
- "name": j["title"],
412
- "issn": issn or "",
413
- "quartile": q if q and q != "-" else None,
414
- "h_index": j.get("h_index"),
415
- "publisher": j.get("publisher", ""),
416
- "open_access":j.get("open_access", "No"),
417
- "country": j.get("country", ""),
418
- "categories": j.get("categories", ""),
419
- "verify_links": vl,
420
- })
421
-
422
- msg = user_message
423
- if fallback_used and not msg:
424
- msg = f"Exact category not found. Showing top journals in the broader '{area}' area."
425
 
 
 
 
 
426
  return {
427
- "result": {
428
- "confidence": confidence,
429
- "normalised": normalised,
430
- "scimago_category": category,
431
- "scimago_area": area,
432
- "user_message": msg,
433
- "journals": enriched,
434
- "total": len(enriched),
435
- }
436
  }
437
 
438
- @app.get("/")
439
- async def root():
440
- if os.path.exists("static/index.html"):
441
- return FileResponse("static/index.html")
442
- return {"status": "Research Navigator API running", "docs": "/docs"}
443
-
444
- if os.path.exists("static"):
445
- if os.path.exists("static/css"):
446
- app.mount("/css", StaticFiles(directory="static/css"), name="css")
447
- if os.path.exists("static/js"):
448
- app.mount("/js", StaticFiles(directory="static/js"), name="js")
 
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
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
+
65
+
66
+ 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="3.5.0",
128
+ lifespan=lifespan,
129
+ )
130
+
131
+
132
+ # ── CORS Middleware ────────────────────────────────────────────────────────────
133
 
134
  app.add_middleware(
135
  CORSMiddleware,
136
+ allow_origins=[
137
+ "https://nikeshn.github.io",
138
+ "https://ku-library.github.io", # added v3.5
139
+ "http://localhost:8080",
140
+ "http://localhost:3000",
141
+ "http://127.0.0.1:5500",
142
+ ],
143
+ allow_credentials=True,
144
+ allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
145
  allow_headers=["*"],
146
+ max_age=86400,
147
  )
148
 
149
+
150
+ # ── Operational Guardrails Middleware ──────────────────────────────────────────
151
+
152
+ @app.middleware("http")
153
+ async def operational_guardrails(request: Request, call_next):
154
+ settings = get_settings()
155
+
156
+ # ── Maintenance mode ───────────────────────────────────────────────────────
157
+ # FIX v3.5: read from JsonRuntimeStore (admin-editable) rather than
158
+ # settings.maintenance_mode (env var, static at startup). This means
159
+ # toggling maintenance via the admin dashboard actually takes effect.
160
+ _maintenance_on = False
161
+ try:
162
+ from src.services.runtime_store import JsonRuntimeStore
163
+ _store = JsonRuntimeStore(settings.config_path, default={"maintenance_mode": False})
164
+ _maintenance_on = bool(_store.load().get("maintenance_mode", False))
165
+ except Exception:
166
+ # If store is unreadable fall back to env-var setting
167
+ _maintenance_on = settings.maintenance_mode
168
+
169
+ _exempt_paths = {"/", "/config", "/admin", "/admin/login", "/admin/auth"}
170
+ if _maintenance_on and request.url.path not in _exempt_paths:
171
+ return JSONResponse(
172
+ status_code=503,
173
+ content={"detail": "LibBee is in maintenance mode. Please try again shortly."},
174
+ )
175
+
176
+ # ── Rate limiting ──────────────────────────────────────────────────────────
177
+ client_ip = request.client.host if request.client else "unknown"
178
+ now = time.time()
179
+
180
+ async with _request_log_lock:
181
+ if _ttl_cache_available:
182
+ timestamps = _request_log.get(client_ip, [])
183
+ timestamps = [t for t in timestamps if now - t < 60]
184
+ if len(timestamps) >= settings.rate_limit_per_minute:
185
+ metrics_service.incr_bucket("errors", "rate_limit")
186
+ return JSONResponse(
187
+ status_code=429,
188
+ content={"detail": "Too many requests. Please slow down and try again."},
189
+ )
190
+ timestamps.append(now)
191
+ _request_log[client_ip] = timestamps
192
+ else:
193
+ from collections import deque
194
+ bucket = _request_log[client_ip] # type: ignore[index]
195
+ while bucket and now - bucket[0] > 60:
196
+ bucket.popleft()
197
+ if len(bucket) >= settings.rate_limit_per_minute:
198
+ metrics_service.incr_bucket("errors", "rate_limit")
199
+ return JSONResponse(
200
+ status_code=429,
201
+ content={"detail": "Too many requests. Please slow down and try again."},
202
+ )
203
+ bucket.append(now)
204
+
205
+ # ── Request execution ──────────────────────────────────────────────────────
206
+ try:
207
+ response = await call_next(request)
208
+ return response
209
+ except Exception as exc:
210
+ logger.exception("Unhandled application error: %s", exc)
211
+ metrics_service.incr_bucket("errors", "unhandled_exception")
212
+ return JSONResponse(status_code=500, content={"detail": "Unexpected server error."})
213
+
214
+
215
+ # ── Router Mounting ────────────────────────────────────────────────────────────
216
+
217
+ from src.api import admin, agent, feedback, search # noqa: E402
218
+
219
+ app.include_router(agent.router, prefix="/agent", tags=["Agent"])
220
+ app.include_router(search.router, prefix="/search", tags=["Search"])
221
+ app.include_router(admin.router, prefix="/admin", tags=["Admin"])
222
+ app.include_router(feedback.router, prefix="/feedback", tags=["Feedback"])
223
+
224
+
225
+ # ── Health & Public Endpoints ──────────────────────────────────────────────────
226
+
227
+ @app.get("/")
228
+ def health_check():
229
+ settings = get_settings()
230
  return {
231
+ "status": "ok",
232
+ "version": "3.5.0",
233
+ "service": "LibBee KU Library AI",
234
+ "rag_ready": rag_service.is_ready(),
235
+ "maintenance_mode": settings.maintenance_mode,
236
+ "endpoints": ["/agent", "/search", "/admin", "/feedback"],
237
  }
238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
 
240
+ @app.get("/config")
241
+ def public_config():
242
+ """Legacy public config endpoint — frontend uses /admin/public-config instead."""
243
+ settings = get_settings()
244
  return {
245
+ "welcome_message": "Hi! I'm LibBee, the Khalifa University Library AI Assistant.",
246
+ "max_results": settings.max_results,
247
+ "maintenance_mode": settings.maintenance_mode,
 
 
 
 
 
 
248
  }
249
 
250
+
251
+ @app.get("/year")
252
+ def get_year():
253
+ from datetime import datetime
254
+ now = datetime.utcnow()
255
+ return {"year": now.year, "month": now.month, "date": now.strftime("%Y-%m-%d")}