nikeshn commited on
Commit
0a18afc
Β·
verified Β·
1 Parent(s): abc7ed3

Update src/api/admin.py

Browse files
Files changed (1) hide show
  1. src/api/admin.py +148 -259
src/api/admin.py CHANGED
@@ -1,22 +1,16 @@
1
  """
2
  admin.py β€” LibBee v3.2
3
 
4
- Changes over v3.1:
5
- 1. Full HTML admin dashboard at GET /admin (login β†’ dashboard)
6
- 2. GET /admin/login β€” login page (redirects to /admin if already authed)
7
- 3. GET /admin/logout β€” clears session, redirects to /admin/login
8
- 4. GET /status β€” system health: RAG chunks, memory, uptime, API key status
9
- 5. POST /admin/rebuild β€” rebuild RAG index (calls rag_service.initialize)
10
- 6. All existing endpoints preserved: /auth, /logout, /config GET+POST,
11
- /rag-status, /metrics
12
- 7. Circular import fix: uses get_rag_service() / get_metrics_service() accessors
13
- 8. Config now supports more keys: max_results, maintenance_mode,
14
- welcome_message, custom_instructions, announcement
15
  """
16
  import secrets
17
  import time
18
- import glob
19
- import os
20
  from typing import Any, Dict
21
 
22
  from fastapi import APIRouter, HTTPException, Request, Response
@@ -31,14 +25,13 @@ admin_sessions: Dict[str, float] = {}
31
 
32
  _START_TIME = time.time()
33
 
34
- # ── Config defaults ────────────────────────────────────────────────────────────
35
-
36
  _CONFIG_DEFAULTS: Dict[str, Any] = {
37
  "max_results": 5,
38
  "maintenance_mode": False,
39
  "welcome_message": "Hi! I'm LibBee, the Khalifa University Library AI Assistant.",
40
  "custom_instructions": "",
41
  "announcement": "",
 
42
  }
43
 
44
 
@@ -73,15 +66,12 @@ def _config_store() -> JsonRuntimeStore:
73
 
74
  def _load_config() -> Dict[str, Any]:
75
  data = _config_store().load()
76
- # Ensure all defaults exist
77
  for k, v in _CONFIG_DEFAULTS.items():
78
  if k not in data:
79
  data[k] = v
80
  return data
81
 
82
 
83
- # ── HTML helpers ───────────────────────────────────────────────────────────────
84
-
85
  def _esc(s: Any) -> str:
86
  return (
87
  str(s or "")
@@ -107,7 +97,7 @@ class ConfigBulkUpdate(BaseModel):
107
  settings: Dict[str, Any]
108
 
109
 
110
- # ── Auth endpoints ─────────────────────────────────────────────────────────────
111
 
112
  @router.post("/auth")
113
  async def admin_auth(req: LoginRequest):
@@ -117,7 +107,8 @@ async def admin_auth(req: LoginRequest):
117
  token = secrets.token_hex(32)
118
  admin_sessions[token] = time.time() + 86400
119
  resp = Response(content='{"status":"ok"}', media_type="application/json")
120
- resp.set_cookie(key="admin_session", value=token, httponly=True, max_age=86400, samesite="lax")
 
121
  return resp
122
 
123
 
@@ -149,48 +140,41 @@ async def admin_login_page(request: Request):
149
  return RedirectResponse(url="/admin")
150
  return HTMLResponse("""<!DOCTYPE html>
151
  <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
152
- <title>LibBee Admin β€” Login</title>
153
  <style>
154
  *{box-sizing:border-box;margin:0;padding:0}
155
- body{font-family:'Segoe UI',system-ui,sans-serif;background:#f0f4f8;display:flex;
156
- justify-content:center;align-items:center;min-height:100vh}
157
- .box{background:#fff;border-radius:16px;padding:40px;width:360px;
158
- box-shadow:0 4px 24px rgba(0,0,0,.1);text-align:center}
159
  h1{color:#003366;font-size:1.3rem;margin-bottom:6px}
160
- .sub{color:#6b7280;font-size:.84rem;margin-bottom:24px}
161
- input{width:100%;padding:12px 14px;border:1.5px solid #d1d5db;border-radius:10px;
162
- font-size:.92rem;margin-bottom:14px;outline:none}
163
- input:focus{border-color:#003366}
164
- .btn{width:100%;padding:12px;background:#003366;color:#fff;border:none;border-radius:10px;
165
- font-weight:700;font-size:.92rem;cursor:pointer}
166
  .btn:hover{background:#004488}
167
- .err{color:#dc2626;font-size:.82rem;margin-bottom:10px;display:none}
168
  </style></head><body>
169
- <div class="box">
170
- <h1>🐝 LibBee Admin</h1>
171
- <div class="sub">Khalifa University Library AI Dashboard</div>
172
- <div class="err" id="err">Incorrect password.</div>
173
- <form onsubmit="return login(event)">
174
- <input type="password" id="pw" placeholder="Admin password" autofocus>
175
- <button type="submit" class="btn">Login β†’</button>
176
  </form>
177
  </div>
178
  <script>
179
- async function login(e){
180
  e.preventDefault();
181
- const r=await fetch('/admin/auth',{method:'POST',
182
- headers:{'Content-Type':'application/json'},
183
- body:JSON.stringify({password:document.getElementById('pw').value})});
184
- if(r.ok){location.href='/admin';}
185
- else{document.getElementById('err').style.display='block';
186
- document.getElementById('pw').value='';
187
- document.getElementById('pw').focus();}
188
  return false;
189
  }
190
  </script></body></html>""")
191
 
192
 
193
- # ── Main dashboard ─────────────────────────────────────────────────────────────
194
 
195
  @router.get("", response_class=HTMLResponse)
196
  async def admin_dashboard(request: Request):
@@ -198,272 +182,180 @@ async def admin_dashboard(request: Request):
198
  return RedirectResponse(url="/admin/login")
199
 
200
  cfg = _load_config()
201
- mm_selected_on = "selected" if cfg.get("maintenance_mode") else ""
202
- mm_selected_off = "" if cfg.get("maintenance_mode") else "selected"
203
 
204
  return HTMLResponse(f"""<!DOCTYPE html>
205
- <html><head><meta charset="utf-8"><title>LibBee Admin</title>
206
  <meta name="viewport" content="width=device-width,initial-scale=1">
207
  <style>
208
  *{{box-sizing:border-box;margin:0;padding:0}}
209
  body{{font-family:'Segoe UI',system-ui,sans-serif;background:#f0f4f8;color:#1a1a2e}}
210
- .topbar{{background:#003366;color:#fff;padding:12px 20px;display:flex;
211
- align-items:center;justify-content:space-between}}
212
- .topbar h1{{font-size:1.05rem;font-weight:700}}
213
- .topbar a{{color:#C8A951;font-size:.8rem;text-decoration:none;padding:5px 12px;
214
- border:1px solid #C8A951;border-radius:8px}}
215
- .main{{max-width:960px;margin:24px auto;padding:0 16px;display:flex;
216
- flex-direction:column;gap:18px}}
217
- .card{{background:#fff;border-radius:14px;padding:20px;
218
- box-shadow:0 2px 12px rgba(0,0,0,.07)}}
219
- .card h2{{font-size:.95rem;font-weight:700;color:#003366;margin-bottom:14px}}
220
- .stat-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px}}
221
- .stat{{background:#f8fafc;border-radius:10px;padding:12px;text-align:center;
222
- border:1px solid #e5e7eb}}
223
- .stat .val{{font-size:1.5rem;font-weight:800;color:#003366}}
224
- .stat .lbl{{font-size:.72rem;color:#6b7280;margin-top:2px}}
225
- .ok{{color:#16a34a}}.warn{{color:#d97706}}.err{{color:#dc2626}}
226
- label{{display:block;font-size:.8rem;font-weight:600;color:#374151;
227
- margin-bottom:3px;margin-top:10px}}
228
- input[type=text],textarea,select{{width:100%;padding:9px 12px;
229
- border:1.5px solid #d1d5db;border-radius:8px;font-size:.86rem;font-family:inherit}}
230
- textarea{{min-height:70px;resize:vertical}}
231
- .btn{{padding:9px 20px;background:#003366;color:#fff;border:none;border-radius:8px;
232
- font-weight:700;cursor:pointer;font-size:.85rem;margin-top:12px}}
233
  .btn:hover{{background:#004488}}
 
234
  .btn-green{{background:#16a34a}}.btn-green:hover{{background:#15803d}}
235
- .btn-amber{{background:#d97706}}.btn-amber:hover{{background:#b45309}}
236
- .btn-red{{background:#dc2626}}.btn-red:hover{{background:#b91c1c}}
237
- .saved{{font-size:.8rem;color:#16a34a;margin-top:8px;display:none}}
238
- table{{width:100%;border-collapse:collapse;font-size:.8rem}}
239
- th{{background:#f1f5f9;padding:7px 10px;text-align:left;font-weight:700;color:#374151}}
240
- td{{padding:6px 10px;border-bottom:1px solid #f1f5f9;word-break:break-word}}
 
 
 
241
  tr:last-child td{{border-bottom:none}}
242
- .tabs{{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}}
243
- .tab{{padding:6px 14px;border-radius:20px;font-size:.8rem;font-weight:600;
244
- cursor:pointer;border:1.5px solid #d1d5db;background:#fff;color:#6b7280}}
245
- .tab.active{{background:#003366;color:#fff;border-color:#003366}}
246
- .section{{display:none}}.section.active{{display:block}}
247
  </style></head>
248
  <body>
249
- <div class="topbar">
250
- <h1>🐝 LibBee β€” Admin Dashboard</h1>
251
- <a href="/admin/logout">Logout</a>
252
- </div>
253
  <div class="main">
254
 
255
- <!-- TABS -->
256
- <div class="tabs">
257
- <div class="tab active" onclick="switchTab('status')">πŸ“Š Status</div>
258
- <div class="tab" onclick="switchTab('config')">βš™οΈ Config</div>
259
- <div class="tab" onclick="switchTab('rag')">🧠 RAG</div>
260
- <div class="tab" onclick="switchTab('metrics')">πŸ“ˆ Metrics</div>
261
- </div>
262
-
263
- <!-- STATUS TAB -->
264
- <div class="section active card" id="tab-status">
265
- <h2>πŸ“Š System Status</h2>
266
- <div class="stat-grid" id="statusGrid">
267
- <div style="color:#6b7280;font-size:.84rem">Loading…</div>
268
- </div>
269
- <div style="margin-top:14px;font-size:.74rem;color:#6b7280" id="statusTime"></div>
270
  </div>
271
 
272
- <!-- CONFIG TAB -->
273
- <div class="section card" id="tab-config">
274
- <h2>βš™οΈ Configuration</h2>
275
  <label>Welcome Message</label>
276
- <input type="text" id="cfg_welcome_message" value="{_esc(cfg.get('welcome_message',''))}">
277
  <label>Custom Instructions (appended to system prompt)</label>
278
- <textarea id="cfg_custom_instructions">{_esc(cfg.get('custom_instructions',''))}</textarea>
279
  <label>Announcement Banner (leave blank to hide)</label>
280
- <input type="text" id="cfg_announcement" value="{_esc(cfg.get('announcement',''))}">
281
- <label>Max Search Results per Query</label>
282
  <input type="text" id="cfg_max_results" value="{_esc(cfg.get('max_results', 5))}" style="width:80px">
283
  <label>Maintenance Mode</label>
284
  <select id="cfg_maintenance_mode">
285
- <option value="false" {mm_selected_off}>Off β€” normal operation</option>
286
- <option value="true" {mm_selected_on}>On β€” show maintenance message</option>
287
  </select>
 
 
288
  <button class="btn" onclick="saveConfig()">πŸ’Ύ Save Configuration</button>
289
- <div class="saved" id="saveMsg">βœ… Saved successfully.</div>
290
  </div>
291
 
292
- <!-- RAG TAB -->
293
- <div class="section card" id="tab-rag">
294
- <h2>🧠 RAG Index</h2>
295
- <div id="ragStats" style="font-size:.84rem;color:#6b7280;margin-bottom:12px">Loading…</div>
296
- <button class="btn btn-green" onclick="rebuildRAG()">πŸ”„ Rebuild Index</button>
297
  <div id="rebuildMsg" style="margin-top:10px;font-size:.84rem"></div>
298
  </div>
299
 
300
- <!-- METRICS TAB -->
301
- <div class="section card" id="tab-metrics">
302
- <h2>πŸ“ˆ Request Metrics</h2>
303
- <div id="metricsContent" style="font-size:.84rem;color:#6b7280">Loading…</div>
304
  </div>
305
 
306
- </div><!-- /main -->
 
 
 
307
 
 
308
  <script>
309
- // ── Tab switching ──────────────────────────────────────────────────────────────
310
- function switchTab(name){{
311
- document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
312
- document.querySelectorAll('.section').forEach(s=>s.classList.remove('active'));
313
- document.querySelectorAll('.tab').forEach(t=>{{
314
- if(t.textContent.toLowerCase().includes(name.substring(0,3)))t.classList.add('active');
315
- }});
316
- const sec=document.getElementById('tab-'+name);
317
- if(sec)sec.classList.add('active');
318
- if(name==='metrics')loadMetrics();
319
- if(name==='rag')loadRAG();
320
- }}
321
-
322
- // ── Status ─────────────────────────────────────────────────────────────────────
323
- async function loadStatus(){{
324
  try{{
325
- const r=await fetch('/admin/status');
326
- if(!r.ok)throw new Error(r.status);
327
  const d=await r.json();
328
- const rag=d.rag_ready;
329
- document.getElementById('statusGrid').innerHTML=`
330
- <div class="stat"><div class="val ${{rag?'ok':'warn'}}">${{rag?'βœ…':'⚠️'}}</div>
331
- <div class="lbl">RAG (${{d.rag_chunks||0}} chunks)</div></div>
332
- <div class="stat"><div class="val ${{d.openai?'ok':'err'}}">${{d.openai?'βœ…':'❌'}}</div>
333
- <div class="lbl">OpenAI API</div></div>
334
- <div class="stat"><div class="val ${{d.anthropic?'ok':'warn'}}">${{d.anthropic?'βœ…':'β€”'}}</div>
335
- <div class="lbl">Anthropic API</div></div>
336
- <div class="stat"><div class="val ${{d.primo?'ok':'err'}}">${{d.primo?'βœ…':'❌'}}</div>
337
- <div class="lbl">PRIMO API</div></div>
338
- <div class="stat"><div class="val">${{d.kb_files||0}}</div>
339
- <div class="lbl">KB Files</div></div>
340
- <div class="stat"><div class="val">${{Math.floor((d.uptime_secs||0)/60)}}m</div>
341
- <div class="lbl">Uptime</div></div>
342
- <div class="stat"><div class="val">${{d.mem_used_mb||0}}MB</div>
343
- <div class="lbl">Memory (${{d.mem_pct||0}}%)</div></div>
344
- <div class="stat"><div class="val ${{d.maintenance?'warn':'ok'}}">${{d.maintenance?'ON':'OFF'}}</div>
345
- <div class="lbl">Maintenance</div></div>
346
  `;
347
- document.getElementById('statusTime').textContent=
348
- 'Last refreshed: '+new Date().toLocaleTimeString();
349
- }}catch(e){{
350
- document.getElementById('statusGrid').innerHTML=
351
- '<span class="err">❌ Cannot reach server: '+e.message+'</span>';
352
- }}
353
  }}
354
 
355
- // ── Config ─────────────────────────────────────────────────────────────────────
356
- async function saveConfig(){{
357
- const keys=['welcome_message','custom_instructions','announcement','max_results','maintenance_mode'];
358
- const settings={{}};
359
- keys.forEach(k=>{{
360
- const el=document.getElementById('cfg_'+k);
361
- if(el)settings[k]=el.value;
362
- }});
363
- const r=await fetch('/admin/config-bulk',{{
364
- method:'POST',
365
- headers:{{'Content-Type':'application/json'}},
366
- body:JSON.stringify({{settings}})
367
- }});
368
- if(r.ok){{
369
- const m=document.getElementById('saveMsg');
370
- m.style.display='block';
371
- setTimeout(()=>m.style.display='none',3000);
372
- }}
373
  }}
374
 
375
- // ── RAG ────────────────────────────────────────────────────────────────────────
376
- async function loadRAG(){{
377
  try{{
378
  const r=await fetch('/admin/rag-status');
379
  const d=await r.json();
380
- document.getElementById('ragStats').innerHTML=
381
- '<strong>Ready:</strong> '+d.ready+
382
- ' | <strong>Chunks:</strong> '+d.chunk_count+
383
- ' | <strong>KB Dir:</strong> '+d.knowledge_dir+
384
- ' | <strong>Hash:</strong> '+d.kb_hash.substring(0,12)+'…';
385
- }}catch(e){{
386
- document.getElementById('ragStats').textContent='Could not load RAG stats.';
387
- }}
 
 
 
388
  }}
389
 
390
- async function rebuildRAG(){{
391
  const el=document.getElementById('rebuildMsg');
392
- el.innerHTML='<span style="color:#d97706">⏳ Rebuilding β€” this may take 30–60 seconds…</span>';
393
  try{{
394
  const r=await fetch('/admin/rebuild',{{method:'POST'}});
395
  const d=await r.json();
396
- if(d.status==='ok')
397
- el.innerHTML='<span style="color:#16a34a">βœ… Done β€” '+d.chunks+' chunks rebuilt.</span>';
398
- else
399
- el.innerHTML='<span class="err">❌ '+d.error+'</span>';
400
- loadRAG();
401
- }}catch(e){{
402
- el.innerHTML='<span class="err">❌ '+e.message+'</span>';
403
- }}
404
  }}
405
 
406
- // ── Metrics ────────────────────────────────────────────────────────────────────
407
- async function loadMetrics(){{
408
  try{{
409
- const r=await fetch('/admin/metrics');
410
  const d=await r.json();
411
- const fmt=v=>JSON.stringify(v,null,2);
412
- let html='<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">';
413
-
414
- // Top-level counters
415
- const counters=[
416
- ['agent_requests','Agent Requests'],
417
- ['search_requests','Search Requests'],
418
- ['feedback_total','Feedback Total'],
419
- ['follow_up_hits','Follow-up Hits'],
420
- ['search_handoffs','Search Handoffs'],
421
- ['summary_fallbacks','Summary Fallbacks'],
422
- ];
423
- html+='<div><h3 style="font-size:.82rem;font-weight:700;color:#003366;margin-bottom:8px">Counters</h3>';
424
- html+='<table><tbody>';
425
- counters.forEach(([k,l])=>{{
426
- html+=`<tr><td style="font-weight:600">${{l}}</td><td>${{d[k]||0}}</td></tr>`;
427
- }});
428
- html+='</tbody></table></div>';
429
-
430
- // Intent breakdown
431
- const intents=d.intents||{{}};
432
- html+='<div><h3 style="font-size:.82rem;font-weight:700;color:#003366;margin-bottom:8px">Intent Breakdown</h3>';
433
- html+='<table><tbody>';
434
- Object.entries(intents).sort((a,b)=>b[1]-a[1]).forEach(([k,v])=>{{
435
- html+=`<tr><td style="font-weight:600">${{k}}</td><td>${{v}}</td></tr>`;
436
- }});
437
- if(!Object.keys(intents).length)html+='<tr><td colspan=2 style="color:#6b7280">No data yet</td></tr>';
438
- html+='</tbody></table></div>';
439
-
440
- // Errors
441
- const errors=d.errors||{{}};
442
- html+='<div><h3 style="font-size:.82rem;font-weight:700;color:#003366;margin-bottom:8px">Errors</h3>';
443
- html+='<table><tbody>';
444
- Object.entries(errors).sort((a,b)=>b[1]-a[1]).forEach(([k,v])=>{{
445
- html+=`<tr><td style="font-weight:600;color:#dc2626">${{k}}</td><td>${{v}}</td></tr>`;
446
- }});
447
- if(!Object.keys(errors).length)html+='<tr><td colspan=2 style="color:#16a34a">No errors πŸŽ‰</td></tr>';
448
- html+='</tbody></table></div>';
449
-
450
- html+='<div style="font-size:.72rem;color:#6b7280">Last updated: '+(d.last_updated?new Date(d.last_updated*1000).toLocaleString():'-')+'</div>';
451
- html+='</div>';
452
- document.getElementById('metricsContent').innerHTML=html;
453
  }}catch(e){{
454
- document.getElementById('metricsContent').innerHTML=
455
- '<span class="err">❌ Could not load metrics: '+e.message+'</span>';
456
  }}
457
  }}
458
 
459
- // Init
 
 
460
  loadStatus();
461
- setInterval(loadStatus,30000);
462
  </script>
463
  </body></html>""")
464
 
465
 
466
- # ── API endpoints (existing + new) ────────────────────────────────────────────
467
 
468
  @router.get("/config")
469
  async def get_config(request: Request):
@@ -475,7 +367,7 @@ async def get_config(request: Request):
475
  async def update_config(req: ConfigUpdate, request: Request):
476
  _require_session(request)
477
  allowed = {"max_results", "maintenance_mode", "welcome_message",
478
- "custom_instructions", "announcement"}
479
  if req.key not in allowed:
480
  raise HTTPException(status_code=400,
481
  detail=f"Unsupported key. Allowed: {sorted(allowed)}")
@@ -501,7 +393,7 @@ async def update_config_bulk(req: ConfigBulkUpdate, request: Request):
501
  except (ValueError, TypeError):
502
  pass
503
  elif key == "maintenance_mode":
504
- data[key] = str(value).lower() in {"1", "true", "yes", "on", "true"}
505
  else:
506
  data[key] = str(value)
507
  _config_store().save(data)
@@ -527,8 +419,7 @@ async def rebuild_rag(request: Request):
527
  settings = get_settings()
528
  if not settings.openai_api_key:
529
  return {"status": "error", "error": "OPENAI_API_KEY not set"}
530
- # Force a fresh build by clearing the cache hash
531
- svc._kb_hash = ""
532
  await svc.initialize(openai_api_key=settings.openai_api_key)
533
  return {"status": "ok", "chunks": len(svc.bm25_corpus), "ready": svc.is_ready()}
534
  except Exception as e:
@@ -550,7 +441,6 @@ async def system_status(request: Request):
550
  _require_session(request)
551
  settings = get_settings()
552
 
553
- # Memory
554
  mem_used_mb = mem_pct = 0
555
  try:
556
  with open("/sys/fs/cgroup/memory.current") as f:
@@ -571,7 +461,6 @@ async def system_status(request: Request):
571
  except Exception:
572
  pass
573
 
574
- # RAG stats
575
  rag_ready = False
576
  rag_chunks = 0
577
  kb_files = 0
 
1
  """
2
  admin.py β€” LibBee v3.2
3
 
4
+ Layout matches app81.py reference exactly:
5
+ - Same topbar, card, stat-grid, table, button CSS
6
+ - Same section order: Analytics β†’ Config β†’ Index Management β†’ Metrics β†’ System Status
7
+ - Login page identical to app81 reference
8
+ - All LibBee-specific endpoints preserved (JsonRuntimeStore, not SQLite)
9
+ - Circular import fix: get_rag_service() / get_metrics_service() accessors
10
+ - No tab system β€” stacked cards like the reference
 
 
 
 
11
  """
12
  import secrets
13
  import time
 
 
14
  from typing import Any, Dict
15
 
16
  from fastapi import APIRouter, HTTPException, Request, Response
 
25
 
26
  _START_TIME = time.time()
27
 
 
 
28
  _CONFIG_DEFAULTS: Dict[str, Any] = {
29
  "max_results": 5,
30
  "maintenance_mode": False,
31
  "welcome_message": "Hi! I'm LibBee, the Khalifa University Library AI Assistant.",
32
  "custom_instructions": "",
33
  "announcement": "",
34
+ "maintenance_message": "LibBee is currently under maintenance. Please try again shortly.",
35
  }
36
 
37
 
 
66
 
67
  def _load_config() -> Dict[str, Any]:
68
  data = _config_store().load()
 
69
  for k, v in _CONFIG_DEFAULTS.items():
70
  if k not in data:
71
  data[k] = v
72
  return data
73
 
74
 
 
 
75
  def _esc(s: Any) -> str:
76
  return (
77
  str(s or "")
 
97
  settings: Dict[str, Any]
98
 
99
 
100
+ # ── Auth ───────────────────────────────────────────────────────────────────────
101
 
102
  @router.post("/auth")
103
  async def admin_auth(req: LoginRequest):
 
107
  token = secrets.token_hex(32)
108
  admin_sessions[token] = time.time() + 86400
109
  resp = Response(content='{"status":"ok"}', media_type="application/json")
110
+ resp.set_cookie(key="admin_session", value=token, httponly=True,
111
+ max_age=86400, samesite="lax")
112
  return resp
113
 
114
 
 
140
  return RedirectResponse(url="/admin")
141
  return HTMLResponse("""<!DOCTYPE html>
142
  <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
143
+ <title>KU Library AI β€” Admin Login</title>
144
  <style>
145
  *{box-sizing:border-box;margin:0;padding:0}
146
+ body{font-family:'Segoe UI',system-ui,sans-serif;background:#f0f4f8;display:flex;justify-content:center;align-items:center;min-height:100vh}
147
+ .login-box{background:#fff;border-radius:16px;padding:40px;width:360px;box-shadow:0 4px 24px rgba(0,0,0,.1);text-align:center}
 
 
148
  h1{color:#003366;font-size:1.3rem;margin-bottom:6px}
149
+ .subtitle{color:#6b7280;font-size:.84rem;margin-bottom:24px}
150
+ input{width:100%;padding:12px 14px;border:1.5px solid #d1d5db;border-radius:10px;font-size:.92rem;margin-bottom:14px}
151
+ input:focus{outline:none;border-color:#003366}
152
+ .btn{width:100%;padding:12px;background:#003366;color:#fff;border:none;border-radius:10px;font-weight:700;font-size:.92rem;cursor:pointer}
 
 
153
  .btn:hover{background:#004488}
154
+ .error{color:#dc2626;font-size:.82rem;margin-bottom:10px;display:none}
155
  </style></head><body>
156
+ <div class="login-box">
157
+ <h1>πŸ” Admin Login</h1>
158
+ <div class="subtitle">KU Library AI Dashboard</div>
159
+ <div class="error" id="err">Incorrect password. Try again.</div>
160
+ <form onsubmit="return doLogin(event)">
161
+ <input type="password" id="pw" placeholder="Enter admin password" autofocus>
162
+ <button type="submit" class="btn">Login</button>
163
  </form>
164
  </div>
165
  <script>
166
+ async function doLogin(e){
167
  e.preventDefault();
168
+ const pw=document.getElementById('pw').value;
169
+ const r=await fetch('/admin/auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pw})});
170
+ if(r.ok){window.location.href='/admin';}
171
+ else{document.getElementById('err').style.display='block';document.getElementById('pw').value='';document.getElementById('pw').focus();}
 
 
 
172
  return false;
173
  }
174
  </script></body></html>""")
175
 
176
 
177
+ # ── Admin dashboard ────────────────────────────────────────────────────────────
178
 
179
  @router.get("", response_class=HTMLResponse)
180
  async def admin_dashboard(request: Request):
 
182
  return RedirectResponse(url="/admin/login")
183
 
184
  cfg = _load_config()
185
+ mm_on = "selected" if cfg.get("maintenance_mode") else ""
186
+ mm_off = "" if cfg.get("maintenance_mode") else "selected"
187
 
188
  return HTMLResponse(f"""<!DOCTYPE html>
189
+ <html><head><meta charset="utf-8"><title>KU Library AI β€” Admin</title>
190
  <meta name="viewport" content="width=device-width,initial-scale=1">
191
  <style>
192
  *{{box-sizing:border-box;margin:0;padding:0}}
193
  body{{font-family:'Segoe UI',system-ui,sans-serif;background:#f0f4f8;color:#1a1a2e}}
194
+ .topbar{{background:#003366;color:#fff;padding:12px 20px;display:flex;align-items:center;justify-content:space-between}}
195
+ .topbar h1{{font-size:1.1rem;font-weight:700}}
196
+ .topbar a{{color:#C8A951;font-size:.82rem;text-decoration:none;padding:5px 12px;border:1px solid #C8A951;border-radius:8px}}
197
+ .main{{max-width:900px;margin:24px auto;padding:0 16px;display:flex;flex-direction:column;gap:20px}}
198
+ .card{{background:#fff;border-radius:14px;padding:20px;box-shadow:0 2px 12px rgba(0,0,0,.07)}}
199
+ .card h2{{font-size:1rem;font-weight:700;color:#003366;margin-bottom:14px}}
200
+ label{{display:block;font-size:.82rem;font-weight:600;color:#374151;margin-bottom:4px;margin-top:10px}}
201
+ input[type=text],textarea,select{{width:100%;padding:9px 12px;border:1.5px solid #d1d5db;border-radius:8px;font-size:.88rem;font-family:inherit}}
202
+ textarea{{min-height:80px;resize:vertical}}
203
+ .btn{{padding:9px 20px;background:#003366;color:#fff;border:none;border-radius:8px;font-weight:700;cursor:pointer;font-size:.88rem;margin-top:12px}}
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  .btn:hover{{background:#004488}}
205
+ .btn-danger{{background:#dc2626}}.btn-danger:hover{{background:#b91c1c}}
206
  .btn-green{{background:#16a34a}}.btn-green:hover{{background:#15803d}}
207
+ .stat-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px}}
208
+ .stat{{background:#f8fafc;border-radius:10px;padding:14px;text-align:center;border:1px solid #e5e7eb}}
209
+ .stat .val{{font-size:1.6rem;font-weight:800;color:#003366}}
210
+ .stat .lbl{{font-size:.74rem;color:#6b7280;margin-top:2px}}
211
+ .status-ok{{color:#16a34a;font-weight:700}}.status-warn{{color:#d97706;font-weight:700}}
212
+ #saveMsg{{font-size:.82rem;color:#16a34a;margin-top:8px;display:none}}
213
+ table{{width:100%;border-collapse:collapse;font-size:.82rem}}
214
+ th{{background:#f1f5f9;padding:8px 10px;text-align:left;font-weight:700;color:#374151}}
215
+ td{{padding:7px 10px;border-bottom:1px solid #f1f5f9;word-break:break-word}}
216
  tr:last-child td{{border-bottom:none}}
 
 
 
 
 
217
  </style></head>
218
  <body>
219
+ <div class="topbar"><h1>🐝 KU Library AI β€” Admin Dashboard</h1><a href="/admin/logout">Logout</a></div>
 
 
 
220
  <div class="main">
221
 
222
+ <div class="card">
223
+ <h2>πŸ“Š Analytics</h2>
224
+ <div class="stat-grid" id="statsGrid"><div style="color:#6b7280;font-size:.84rem">Loading…</div></div>
 
 
 
 
 
 
 
 
 
 
 
 
225
  </div>
226
 
227
+ <div class="card">
228
+ <h2>βš™οΈ Bot Configuration</h2>
 
229
  <label>Welcome Message</label>
230
+ <input type="text" id="cfg_welcome_message" value="{_esc(cfg.get('welcome_message', ''))}">
231
  <label>Custom Instructions (appended to system prompt)</label>
232
+ <textarea id="cfg_custom_instructions">{_esc(cfg.get('custom_instructions', ''))}</textarea>
233
  <label>Announcement Banner (leave blank to hide)</label>
234
+ <input type="text" id="cfg_announcement" value="{_esc(cfg.get('announcement', ''))}">
235
+ <label>Max Search Results (per query)</label>
236
  <input type="text" id="cfg_max_results" value="{_esc(cfg.get('max_results', 5))}" style="width:80px">
237
  <label>Maintenance Mode</label>
238
  <select id="cfg_maintenance_mode">
239
+ <option value="false" {mm_off}>Off</option>
240
+ <option value="true" {mm_on}>On</option>
241
  </select>
242
+ <label>Maintenance Message</label>
243
+ <input type="text" id="cfg_maintenance_message" value="{_esc(cfg.get('maintenance_message', ''))}">
244
  <button class="btn" onclick="saveConfig()">πŸ’Ύ Save Configuration</button>
245
+ <div id="saveMsg">βœ… Saved!</div>
246
  </div>
247
 
248
+ <div class="card">
249
+ <h2>πŸ”„ Index Management</h2>
250
+ <p style="font-size:.84rem;color:#6b7280;margin-bottom:12px">Rebuild the FAISS + BM25 hybrid index from knowledge files in the KB directory.</p>
251
+ <div id="ragInfo" style="font-size:.82rem;color:#374151;margin-bottom:12px">Loading RAG status…</div>
252
+ <button class="btn btn-green" onclick="rebuildIndex()">πŸ”„ Rebuild Index</button>
253
  <div id="rebuildMsg" style="margin-top:10px;font-size:.84rem"></div>
254
  </div>
255
 
256
+ <div class="card">
257
+ <h2>πŸ“‹ Request Metrics</h2>
258
+ <div id="metricsTable" style="overflow-x:auto"><div style="color:#6b7280;font-size:.84rem">Loading…</div></div>
 
259
  </div>
260
 
261
+ <div class="card">
262
+ <h2>πŸ”Œ System Status</h2>
263
+ <div id="sysStatus"><div style="color:#6b7280;font-size:.84rem">Loading…</div></div>
264
+ </div>
265
 
266
+ </div>
267
  <script>
268
+ async function loadStats(){{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  try{{
270
+ const r=await fetch('/admin/metrics');
 
271
  const d=await r.json();
272
+ document.getElementById('statsGrid').innerHTML=`
273
+ <div class="stat"><div class="val">${{d.agent_requests||0}}</div><div class="lbl">Agent Requests</div></div>
274
+ <div class="stat"><div class="val">${{d.search_requests||0}}</div><div class="lbl">Search Requests</div></div>
275
+ <div class="stat"><div class="val">${{d.follow_up_hits||0}}</div><div class="lbl">Follow-up Hits</div></div>
276
+ <div class="stat"><div class="val">${{d.search_handoffs||0}}</div><div class="lbl">Snapshots</div></div>
277
+ <div class="stat"><div class="val">${{d.feedback_total||0}}</div><div class="lbl">Feedback</div></div>
 
 
 
 
 
 
 
 
 
 
 
 
278
  `;
279
+ }}catch(e){{document.getElementById('statsGrid').innerHTML='<span style="color:#dc2626">Analytics unavailable</span>';}}
 
 
 
 
 
280
  }}
281
 
282
+ async function loadMetrics(){{
283
+ try{{
284
+ const r=await fetch('/admin/metrics');
285
+ const d=await r.json();
286
+ const intents=d.intents||{{}};
287
+ const errors=d.errors||{{}};
288
+ let rows=Object.entries(intents).sort((a,b)=>b[1]-a[1])
289
+ .map(([k,v])=>`<tr><td>${{k}}</td><td>${{v}}</td><td style="color:#6b7280">intent</td></tr>`).join('');
290
+ rows+=Object.entries(errors).sort((a,b)=>b[1]-a[1])
291
+ .map(([k,v])=>`<tr><td style="color:#dc2626">${{k}}</td><td>${{v}}</td><td style="color:#6b7280">error</td></tr>`).join('');
292
+ document.getElementById('metricsTable').innerHTML=rows
293
+ ?`<table><thead><tr><th>Key</th><th>Count</th><th>Type</th></tr></thead><tbody>${{rows}}</tbody></table>`
294
+ :'<span style="color:#6b7280;font-size:.84rem">No data yet.</span>';
295
+ }}catch(e){{document.getElementById('metricsTable').innerHTML='<span style="color:#dc2626">Could not load metrics</span>';}}
 
 
 
 
296
  }}
297
 
298
+ async function loadRAGInfo(){{
 
299
  try{{
300
  const r=await fetch('/admin/rag-status');
301
  const d=await r.json();
302
+ document.getElementById('ragInfo').innerHTML=
303
+ `<strong>Ready:</strong> ${{d.ready}} &nbsp;|&nbsp; <strong>Chunks:</strong> ${{d.chunk_count}} &nbsp;|&nbsp; <strong>KB Dir:</strong> ${{d.knowledge_dir}}`;
304
+ }}catch(e){{document.getElementById('ragInfo').textContent='Could not load RAG info.';}}
305
+ }}
306
+
307
+ async function saveConfig(){{
308
+ const keys=['welcome_message','custom_instructions','announcement','max_results','maintenance_mode','maintenance_message'];
309
+ const settings={{}};
310
+ keys.forEach(k=>{{const el=document.getElementById('cfg_'+k);if(el)settings[k]=el.value;}});
311
+ const r=await fetch('/admin/config-bulk',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{settings}})}});
312
+ if(r.ok){{const m=document.getElementById('saveMsg');m.style.display='block';setTimeout(()=>m.style.display='none',3000);}}
313
  }}
314
 
315
+ async function rebuildIndex(){{
316
  const el=document.getElementById('rebuildMsg');
317
+ el.innerHTML='<span style="color:#d97706">Rebuilding… this may take 30–60 seconds</span>';
318
  try{{
319
  const r=await fetch('/admin/rebuild',{{method:'POST'}});
320
  const d=await r.json();
321
+ if(d.status==='ok') el.innerHTML=`<span style="color:#16a34a">βœ… Done β€” ${{d.chunks}} chunks rebuilt.</span>`;
322
+ else el.innerHTML=`<span style="color:#dc2626">Error: ${{d.error}}</span>`;
323
+ loadRAGInfo();
324
+ }}catch(e){{el.innerHTML=`<span style="color:#dc2626">Error: ${{e.message}}</span>`;}}
 
 
 
 
325
  }}
326
 
327
+ async function loadStatus(){{
328
+ const el=document.getElementById('sysStatus');
329
  try{{
330
+ const r=await fetch('/admin/status');
331
  const d=await r.json();
332
+ el.innerHTML=`
333
+ <div class="stat-grid">
334
+ <div class="stat"><div class="val ${{d.rag_ready?'status-ok':'status-warn'}}">${{d.rag_ready?'βœ…':'⚠️'}}</div><div class="lbl">RAG (${{d.rag_chunks||0}} chunks)</div></div>
335
+ <div class="stat"><div class="val ${{d.openai?'status-ok':'status-warn'}}">${{d.openai?'βœ…':'❌'}}</div><div class="lbl">OpenAI API</div></div>
336
+ <div class="stat"><div class="val ${{d.anthropic?'status-ok':'status-warn'}}">${{d.anthropic?'βœ…':'β€”'}}</div><div class="lbl">Anthropic API</div></div>
337
+ <div class="stat"><div class="val ${{d.primo?'status-ok':'status-warn'}}">${{d.primo?'βœ…':'❌'}}</div><div class="lbl">PRIMO API</div></div>
338
+ <div class="stat"><div class="val">${{d.kb_files||0}}</div><div class="lbl">KB Files</div></div>
339
+ <div class="stat"><div class="val">${{Math.floor((d.uptime_secs||0)/60)}}m</div><div class="lbl">Uptime</div></div>
340
+ <div class="stat"><div class="val">${{d.mem_used_mb||0}}MB</div><div class="lbl">Memory (${{d.mem_pct||0}}%)</div></div>
341
+ <div class="stat"><div class="val ${{d.maintenance?'status-warn':'status-ok'}}">${{d.maintenance?'ON':'OFF'}}</div><div class="lbl">Maintenance</div></div>
342
+ </div>
343
+ <div style="margin-top:8px;font-size:.74rem;color:#6b7280">Last checked: ${{new Date().toLocaleTimeString()}}</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  }}catch(e){{
345
+ el.innerHTML='<span style="color:#dc2626">❌ Cannot reach server: '+e.message+'</span>';
 
346
  }}
347
  }}
348
 
349
+ loadStats();
350
+ loadMetrics();
351
+ loadRAGInfo();
352
  loadStatus();
353
+ setInterval(loadStatus, 30000);
354
  </script>
355
  </body></html>""")
356
 
357
 
358
+ # ── API endpoints ──────────────────────────────────────────────────────────────
359
 
360
  @router.get("/config")
361
  async def get_config(request: Request):
 
367
  async def update_config(req: ConfigUpdate, request: Request):
368
  _require_session(request)
369
  allowed = {"max_results", "maintenance_mode", "welcome_message",
370
+ "custom_instructions", "announcement", "maintenance_message"}
371
  if req.key not in allowed:
372
  raise HTTPException(status_code=400,
373
  detail=f"Unsupported key. Allowed: {sorted(allowed)}")
 
393
  except (ValueError, TypeError):
394
  pass
395
  elif key == "maintenance_mode":
396
+ data[key] = str(value).lower() in {"1", "true", "yes", "on"}
397
  else:
398
  data[key] = str(value)
399
  _config_store().save(data)
 
419
  settings = get_settings()
420
  if not settings.openai_api_key:
421
  return {"status": "error", "error": "OPENAI_API_KEY not set"}
422
+ svc._kb_hash = "" # force fresh build
 
423
  await svc.initialize(openai_api_key=settings.openai_api_key)
424
  return {"status": "ok", "chunks": len(svc.bm25_corpus), "ready": svc.is_ready()}
425
  except Exception as e:
 
441
  _require_session(request)
442
  settings = get_settings()
443
 
 
444
  mem_used_mb = mem_pct = 0
445
  try:
446
  with open("/sys/fs/cgroup/memory.current") as f:
 
461
  except Exception:
462
  pass
463
 
 
464
  rag_ready = False
465
  rag_chunks = 0
466
  kb_files = 0