diff --git "a/src/api/admin.py" "b/src/api/admin.py"
--- "a/src/api/admin.py"
+++ "b/src/api/admin.py"
@@ -9,127 +9,131 @@ Security features:
- Login page JS shows server error message (lockout, remaining attempts)
- Short-lived read-only browser tokens for Worker analytics
"""
-
-# ── Imports ────────────────────────────────────────────────────────────────────
-
+
+# ── Imports ────────────────────────────────────────────────────────────────────
+
import base64
import hashlib
import hmac
import json
-import os
+import logging
import secrets
import time
from typing import Any, Dict, Optional
from urllib.parse import urlsplit
-
-from fastapi import APIRouter, HTTPException, Request, Response
-from fastapi.responses import HTMLResponse, RedirectResponse
+
+from fastapi import APIRouter, HTTPException, Request, Response
+from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel, ConfigDict, Field
-
-from src.config import get_settings, LIBBEE_VERSION
-from src.services.runtime_store import JsonRuntimeStore
-
-router = APIRouter()
-
-_START_TIME = time.time()
-
-_CONFIG_DEFAULTS: Dict[str, Any] = {
- "max_results": 5,
- "maintenance_mode": False,
- "welcome_message": "Hi! I'm LibBee, the Khalifa University Library AI Assistant.",
- "custom_instructions": "",
- "announcement": "",
- "maintenance_message": "LibBee is currently under maintenance. Please try again shortly.",
-}
-
-
-# ── Session Persistence ────────────────────────────────────────────────────────
-
-admin_sessions: Dict[str, float] = {}
-
-# ── Admin login lockout ────────────────────────────────────────────────────────
-# Tracks failed login attempts per IP.
-# After _MAX_LOGIN_ATTEMPTS failures within _LOCKOUT_WINDOW seconds,
-# that IP is locked out for _LOCKOUT_DURATION seconds.
-_login_attempts: Dict[str, list] = {} # ip → [timestamp, ...]
-_MAX_LOGIN_ATTEMPTS = 5
-_LOCKOUT_WINDOW = 300 # 5 minutes — window in which attempts are counted
-_LOCKOUT_DURATION = 900 # 15 minutes — lockout duration after max attempts
-
-
+
+from src.config import get_settings, LIBBEE_VERSION
+from src.services.runtime_store import JsonRuntimeStore
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+_START_TIME = time.time()
+
+_CONFIG_DEFAULTS: Dict[str, Any] = {
+ "max_results": 5,
+ "maintenance_mode": False,
+ "welcome_message": "Hi! I'm LibBee, the Khalifa University Library AI Assistant.",
+ "custom_instructions": "",
+ "announcement": "",
+ "maintenance_message": "LibBee is currently under maintenance. Please try again shortly.",
+}
+
+
+# ── Session Persistence ────────────────────────────────────────────────────────
+
+admin_sessions: Dict[str, float] = {}
+
+# ── Admin login lockout ────────────────────────────────────────────────────────
+# Tracks failed login attempts per IP.
+# After _MAX_LOGIN_ATTEMPTS failures within _LOCKOUT_WINDOW seconds,
+# that IP is locked out for _LOCKOUT_DURATION seconds.
+_login_attempts: Dict[str, list] = {} # ip → [timestamp, ...]
+_login_lockouts: Dict[str, float] = {} # ip → absolute expiry (separate from attempt window)
+_MAX_LOGIN_ATTEMPTS = 5
+_LOCKOUT_WINDOW = 300 # 5 minutes — window in which attempts are counted
+_LOCKOUT_DURATION = 900 # 15 minutes — lockout duration after max attempts
+
+
def _sessions_file() -> str:
return str(get_settings().runtime_path / "admin_sessions.json")
-
-
-def _load_sessions() -> None:
- global admin_sessions
- try:
- with open(_sessions_file(), "r", encoding="utf-8") as f:
- data = json.load(f)
- now = time.time()
- valid = {t: exp for t, exp in data.items()
- if isinstance(exp, (int, float)) and exp > now}
- admin_sessions.update(valid)
- except Exception:
- pass
-
-
-def _save_sessions() -> None:
- try:
- now = time.time()
- valid = {t: exp for t, exp in admin_sessions.items() if exp > now}
- with open(_sessions_file(), "w", encoding="utf-8") as f:
- json.dump(valid, f)
- except Exception:
- pass
-
-
-def _cleanup_sessions() -> None:
- now = time.time()
- expired = [t for t, exp in admin_sessions.items() if exp <= now]
- for t in expired:
- admin_sessions.pop(t, None)
- try:
- _load_sessions()
- except Exception:
- pass
-
-
-def _require_session(request: Request) -> None:
- _cleanup_sessions()
- token = request.cookies.get("admin_session")
- if not token or token not in admin_sessions:
- raise HTTPException(status_code=401, detail="Not authenticated")
-
-
-def _has_session(request: Request) -> bool:
- _cleanup_sessions()
- token = request.cookies.get("admin_session")
- return bool(token and token in admin_sessions)
-
-
-# ── Config Store ───────────────────────────────────────────────────────────────
-
-def _config_store() -> JsonRuntimeStore:
- settings = get_settings()
- return JsonRuntimeStore(settings.config_path, default=_CONFIG_DEFAULTS)
-
-
-def _load_config() -> Dict[str, Any]:
- data = _config_store().load()
- for k, v in _CONFIG_DEFAULTS.items():
- if k not in data:
- data[k] = v
- return data
-
-
+
+
+def _load_sessions() -> None:
+ global admin_sessions
+ try:
+ from pathlib import Path
+ data = JsonRuntimeStore(Path(_sessions_file())).load()
+ now = time.time()
+ valid = {t: exp for t, exp in data.items()
+ if isinstance(exp, (int, float)) and exp > now}
+ admin_sessions.clear()
+ admin_sessions.update(valid)
+ except Exception:
+ pass
+
+
+def _save_sessions() -> None:
+ try:
+ from pathlib import Path
+ now = time.time()
+ valid = {t: exp for t, exp in admin_sessions.items() if exp > now}
+ JsonRuntimeStore(Path(_sessions_file())).save(valid)
+ except Exception as exc:
+ logger.error("Could not persist admin sessions: %s", type(exc).__name__)
+ raise HTTPException(status_code=503, detail="Session storage unavailable") from exc
+
+
+def _cleanup_sessions() -> None:
+ now = time.time()
+ expired = [t for t, exp in admin_sessions.items() if exp <= now]
+ for t in expired:
+ admin_sessions.pop(t, None)
+ try:
+ _load_sessions()
+ except Exception:
+ pass
+
+
+def _require_session(request: Request) -> None:
+ _cleanup_sessions()
+ token = request.cookies.get("admin_session")
+ if not token or token not in admin_sessions:
+ raise HTTPException(status_code=401, detail="Not authenticated")
+
+
+def _has_session(request: Request) -> bool:
+ _cleanup_sessions()
+ token = request.cookies.get("admin_session")
+ return bool(token and token in admin_sessions)
+
+
+# ── Config Store ───────────────────────────────────────────────────────────────
+
+def _config_store() -> JsonRuntimeStore:
+ settings = get_settings()
+ return JsonRuntimeStore(settings.config_path, default=settings.runtime_defaults)
+
+
+def _load_config() -> Dict[str, Any]:
+ data = _config_store().load()
+ for k, v in _CONFIG_DEFAULTS.items():
+ if k not in data:
+ data[k] = v
+ return data
+
+
def _esc(s: Any) -> str:
- return (
- str(s or "")
- .replace("&", "&")
- .replace("<", "<")
- .replace(">", ">")
- .replace('"', """)
+ return (
+ str(s or "")
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace('"', """)
)
@@ -175,263 +179,311 @@ def _make_worker_browser_token(secret: str, now: Optional[int] = None) -> tuple[
signature = hmac.new(secret.encode("utf-8"), signing_input.encode("ascii"), hashlib.sha256).digest()
encoded_signature = base64.urlsafe_b64encode(signature).decode("ascii").rstrip("=")
return f"{signing_input}.{encoded_signature}", expires_at
-
-
-# ── Pydantic Models ────────────────────────────────────────────────────────────
-
+
+
+# ── Pydantic Models ────────────────────────────────────────────────────────────
+
class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
password: str = Field(min_length=1, max_length=256)
-
-
+
+
class ConfigUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str = Field(min_length=1, max_length=40)
value: str = Field(max_length=5000)
-
-
+
+
class ConfigBulkUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
settings: Dict[str, Any] = Field(max_length=6)
-
-
-# ── Auth Endpoints ─────────────────────────────────────────────────────────────
-
-@router.post("/auth")
-async def admin_auth(req: LoginRequest, request: Request):
- settings = get_settings()
- client_ip = request.client.host if request.client else "unknown"
- now = time.time()
-
- # ── Lockout check ─────────────────────────────────────────────────────────
- attempts = _login_attempts.get(client_ip, [])
- attempts = [t for t in attempts if now - t < _LOCKOUT_WINDOW]
- if len(attempts) >= _MAX_LOGIN_ATTEMPTS:
- wait = int(_LOCKOUT_DURATION - (now - attempts[0]))
- raise HTTPException(
- status_code=429,
- detail=f"Too many failed login attempts. Try again in {wait} seconds."
- )
-
- # ── Password check ────────────────────────────────────────────────────────
- if not settings.admin_password or not secrets.compare_digest(
- req.password.encode(), settings.admin_password.encode()
- ):
- attempts.append(now)
- _login_attempts[client_ip] = attempts
- remaining = _MAX_LOGIN_ATTEMPTS - len(attempts)
- if remaining <= 0:
- detail = f"Too many failed login attempts. Try again in {int(_LOCKOUT_DURATION / 60)} minutes."
- else:
- detail = f"Invalid password. {remaining} attempt{'s' if remaining != 1 else ''} remaining before lockout."
- raise HTTPException(status_code=401, detail=detail)
-
- # ── Success — clear attempts and issue session ─────────────────────────────
- _login_attempts.pop(client_ip, None)
- token = secrets.token_hex(32)
- admin_sessions[token] = time.time() + 86400
- _save_sessions()
- resp = Response(content='{"status":"ok"}', media_type="application/json")
- resp.set_cookie(key="admin_session", value=token, httponly=True,
- max_age=86400, samesite="lax", secure=True)
- return resp
-
-
-@router.post("/logout")
-async def admin_logout_post(request: Request):
- token = request.cookies.get("admin_session")
- if token:
- admin_sessions.pop(token, None)
- _save_sessions()
- resp = Response(content='{"status":"ok"}', media_type="application/json")
- resp.delete_cookie("admin_session")
- return resp
-
-
-@router.get("/logout")
-async def admin_logout_get(request: Request):
- token = request.cookies.get("admin_session")
- if token:
- admin_sessions.pop(token, None)
- resp = RedirectResponse(url="/admin/login")
- resp.delete_cookie("admin_session")
- return resp
-
-
-# ── Login Page ─────────────────────────────────────────────────────────────────
-
-@router.get("/login", response_class=HTMLResponse)
-async def admin_login_page(request: Request):
- if _has_session(request):
- return RedirectResponse(url="/admin")
- return HTMLResponse("""
-
-KU Library AI — Admin Login
-
-
-
🔐 Admin Login
-
KU Library AI Dashboard
-
Incorrect password. Try again.
-
-
-""")
-
-
-# ── Admin Dashboard ────────────────────────────────────────────────────────────
-
-@router.get("", response_class=HTMLResponse)
-async def admin_dashboard(request: Request):
- if not _has_session(request):
- return RedirectResponse(url="/admin/login")
-
- try:
- cfg = _load_config()
- except Exception:
- cfg = dict(_CONFIG_DEFAULTS)
- mm_on = "selected" if cfg.get("maintenance_mode") else ""
- mm_off = "" if cfg.get("maintenance_mode") else "selected"
-
+
+
+def _validated_config_values(values: Dict[str, Any]) -> Dict[str, Any]:
+ limits = {"welcome_message": 500, "custom_instructions": 5000,
+ "announcement": 500, "maintenance_message": 300}
+ cleaned = {}
+ for key, value in values.items():
+ if key == "max_results":
+ try:
+ # Reject fractional numbers instead of silently rounding.
+ number = int(str(value))
+ except (ValueError, TypeError):
+ raise HTTPException(status_code=422, detail="max_results must be an integer from 1 to 20")
+ if not 1 <= number <= 20:
+ raise HTTPException(status_code=422, detail="max_results must be from 1 to 20")
+ cleaned[key] = number
+ elif key == "maintenance_mode":
+ normalized = str(value).lower().strip()
+ if normalized not in {"1", "true", "yes", "on", "0", "false", "no", "off"}:
+ raise HTTPException(status_code=422, detail="maintenance_mode must be true or false")
+ cleaned[key] = normalized in {"1", "true", "yes", "on"}
+ elif key in limits:
+ if not isinstance(value, str) or len(value) > limits[key]:
+ raise HTTPException(status_code=422, detail=f"{key} must be text of at most {limits[key]} characters")
+ cleaned[key] = value
+ else:
+ raise HTTPException(status_code=422, detail=f"Unsupported setting: {key}")
+ return cleaned
+
+
+# ── Auth Endpoints ─────────────────────────────────────────────────────────────
+
+@router.post("/auth")
+async def admin_auth(req: LoginRequest, request: Request):
+ settings = get_settings()
+ if not settings.admin_password:
+ raise HTTPException(status_code=503, detail="Admin login is not configured")
+ client_ip = request.client.host if request.client else "unknown"
+ now = time.time()
+
+ # ── Lockout check ─────────────────────────────────────────────────────────
+ # The lockout survives the shorter rolling attempt window. Previously the
+ # fifth attempt was forgotten after five minutes, releasing a 15-minute ban.
+ for ip, expiry in list(_login_lockouts.items()):
+ if expiry <= now:
+ _login_lockouts.pop(ip, None)
+ for ip, previous in list(_login_attempts.items()):
+ recent = [t for t in previous if now - t < _LOCKOUT_WINDOW]
+ if recent:
+ _login_attempts[ip] = recent
+ else:
+ _login_attempts.pop(ip, None)
+ if client_ip in _login_lockouts:
+ wait = max(1, int(_login_lockouts[client_ip] - now))
+ raise HTTPException(
+ status_code=429,
+ detail=f"Too many failed login attempts. Try again in {wait} seconds.",
+ headers={"Retry-After": str(wait)},
+ )
+ attempts = _login_attempts.get(client_ip, [])
+
+ # ── Password check ────────────────────────────────────────────────────────
+ if not settings.admin_password or not secrets.compare_digest(
+ req.password.encode(), settings.admin_password.encode()
+ ):
+ attempts.append(now)
+ _login_attempts[client_ip] = attempts
+ remaining = _MAX_LOGIN_ATTEMPTS - len(attempts)
+ if remaining <= 0:
+ _login_lockouts[client_ip] = now + _LOCKOUT_DURATION
+ detail = f"Too many failed login attempts. Try again in {int(_LOCKOUT_DURATION / 60)} minutes."
+ else:
+ detail = f"Invalid password. {remaining} attempt{'s' if remaining != 1 else ''} remaining before lockout."
+ raise HTTPException(status_code=429 if remaining <= 0 else 401, detail=detail)
+
+ # ── Success — clear attempts and issue session ─────────────────────────────
+ _login_attempts.pop(client_ip, None)
+ _cleanup_sessions()
+ token = secrets.token_hex(32)
+ admin_sessions[token] = time.time() + 86400
+ _save_sessions()
+ resp = Response(content='{"status":"ok"}', media_type="application/json")
+ resp.set_cookie(key="admin_session", value=token, httponly=True,
+ max_age=86400, samesite="lax", secure=settings.environment == "production")
+ return resp
+
+
+@router.post("/logout")
+async def admin_logout_post(request: Request):
+ _cleanup_sessions()
+ token = request.cookies.get("admin_session")
+ if token:
+ admin_sessions.pop(token, None)
+ _save_sessions()
+ resp = Response(content='{"status":"ok"}', media_type="application/json")
+ resp.delete_cookie("admin_session")
+ return resp
+
+
+@router.get("/logout")
+async def admin_logout_get(request: Request):
+ _cleanup_sessions()
+ token = request.cookies.get("admin_session")
+ if token:
+ admin_sessions.pop(token, None)
+ _save_sessions()
+ resp = RedirectResponse(url="/admin/login")
+ resp.delete_cookie("admin_session")
+ return resp
+
+
+# ── Login Page ─────────────────────────────────────────────────────────────────
+
+@router.get("/login", response_class=HTMLResponse)
+async def admin_login_page(request: Request):
+ if _has_session(request):
+ return RedirectResponse(url="/admin")
+ return HTMLResponse("""
+
+KU Library AI — Admin Login
+
+
+
🔐 Admin Login
+
KU Library AI Dashboard
+
Incorrect password. Try again.
+
+
+""")
+
+
+# ── Admin Dashboard ────────────────────────────────────────────────────────────
+
+@router.get("", response_class=HTMLResponse)
+async def admin_dashboard(request: Request):
+ if not _has_session(request):
+ return RedirectResponse(url="/admin/login")
+
+ try:
+ cfg = _load_config()
+ except Exception:
+ cfg = dict(_CONFIG_DEFAULTS)
+ mm_on = "selected" if cfg.get("maintenance_mode") else ""
+ mm_off = "" if cfg.get("maintenance_mode") else "selected"
+
return HTMLResponse(f"""
-KU Library AI — Admin
-
-
-
-🐝 KU Library AI — Admin Dashboard Logout
-
-
-
-
-
-
⚙️ Bot Configuration
-
Welcome Message
-
-
Custom Instructions (appended to system prompt)
-
-
Announcement Banner (leave blank to hide)
-
-
Max Search Results (per query)
-
-
Maintenance Mode
-
- Off
- On
-
-
Maintenance Message
-
-
💾 Save Configuration
-
✅ Saved!
-
-
-
-
🔄 Index Management
-
Rebuild the FAISS + BM25 hybrid index from knowledge files in the KB directory.
-
Loading RAG status…
-
🔄 Rebuild Index
-
-
-
-
-
-
-
📈 Cloudflare Analytics (Persistent)
-
-
-
-
-
-
📋 Recent Queries (Cloudflare D1)
-
-
-
-
-
📊 Reports
-
- Period:
-
- Last 7 days
- Last 30 days
- Last 90 days
- All time
-
- Report:
-
- 1. Usage overview
- 2. What people ask about
- 3. When people use it
- 4. How fast it answers
- 5. Questions we could not answer well
- 6. Service health
- Everything (printable summary)
-
- Show report
- Print / PDF
- Download CSV
-
-
Choose a report and click "Show report".
-
-
-
-
-
-
-""")
-
-
-# ── API Endpoints ──────────────────────────────────────────────────────────────
-
+
+async function loadStats(){{
+ try{{
+ const r=await fetch('/admin/metrics');
+ const d=await r.json();
+ document.getElementById('statsGrid').innerHTML=`
+ ${{d.agent_requests||0}}
Requests (session)
+ ${{d.search_requests||0}}
Searches (session)
+ ${{d.follow_up_hits||0}}
Follow-ups (session)
+ ${{d.feedback_total||0}}
Feedback (session)
+ `;
+ }}catch(e){{document.getElementById('statsGrid').innerHTML='Unavailable ';}}
+}}
+
+async function loadCFAnalytics(){{
+ try{{
+ const res=await cfFetch('/analytics');
+ const d=res.data;
+ document.getElementById('cfStatsGrid').innerHTML=`
+ ${{d.total||0}}
Total Queries
+
+
+ ${{(d.avg_time||0).toFixed(2)}}s
Avg Response
+
+ `;
+ const tools=(d.tools||[]).slice(0,15);
+ let rows=tools.map(t=>`${{t.tool_used||"unknown"}} ${{t.c}} `).join('');
+ document.getElementById('cfToolsTable').innerHTML=rows
+ ?`Intent:Tool Count ${{rows}}
`:'';
+ }}catch(e){{
+ document.getElementById('cfStatsGrid').innerHTML='CF D1 unavailable: '+cfEscape(e.message)+' ';
+ }}
+}}
+
+async function loadRecentQueries(){{
+ try{{
+ const res=await cfFetch('/analytics/recent');
+ const d=res.data;
+ if(d.error) throw new Error(d.error);
+ const rows=(d.results||[]).map(q=>`
+ ${{q.timestamp||""}}
+ ${{(q.question||"").substring(0,100)}}
+ ${{q.tool_used||""}}
+ ${{q.model||""}}
+ ${{(q.response_time||0).toFixed(2)}}s
+ ${{q.result_count||0}}
+ `).join('');
+ document.getElementById('recentTable').innerHTML=rows
+ ?`Time Question Tool Model Time # ${{rows}}
`
+ :'No queries yet. ';
+ }}catch(e){{
+ document.getElementById('recentTable').innerHTML=''+cfEscape(e.message)+' ';
+ }}
+}}
+
+// ── Plain-language reporting ────────────────────────────────────────────────
+// Internal routing codes are meaningless to a reader, so every code is mapped
+// to a phrase a librarian or director would recognise. Unknown codes degrade to
+// a title-cased version of the code rather than disappearing.
+const INTENT_LABELS = {{
+ 'library_info:rag_hybrid_search':'Library information (from our guides)',
+ 'library_info:libcal_live_hours':'Opening hours',
+ 'library_info:hours_hardcoded':'Opening hours',
+ 'library_info:libcal_live_events':'Library events & workshops',
+ 'library_info:fulltext_chain':'How to get the full text of an article',
+ 'library_info:database_recommendation':'Which database should I use',
+ 'library_info:staff_name_match':'Contacting a named staff member',
+ 'library_info:staff_role_match':'Who to contact for help',
+ 'library_info:theses_guide':'Finding theses & dissertations',
+ 'library_info:campus_hardcoded':'Campus & location',
+ 'library_info':'General library information',
+ 'search_academic:primo_inline':'Finding articles & books',
+ 'search_academic:primo_browser_link':'Finding articles & books (opened in catalogue)',
+ 'search_academic:research_snapshot':'Research starting points',
+ 'search_academic:follow_up_resolution, both_resources':'Refining an earlier search',
+ 'search_academic':'Finding articles & books',
+ 'search_medical:primo_inline':'Medical & health literature',
+ 'search_medical:primo_browser_link':'Medical & health literature (opened in catalogue)',
+ 'search_medical':'Medical & health literature',
+ 'general:web_search':'General questions (answered from the web)',
+ 'general_recent:web_search':'Current events & news',
+ 'general_topic':'General topic questions','general_recent':'Current events & news',
+ 'general':'General questions',
+ 'social_greeting:libbee_casual':'Greetings & small talk',
+ 'social_greeting:':'Greetings & small talk','social_greeting':'Greetings & small talk',
+ 'sensitive_blocked:sensitive_intent_guard':'Sensitive topics (safely redirected)',
+ 'blocked:injection_guard':'Blocked misuse attempts',
+ 'not_in_kb':'No information available in our guides',
+ 'unknown':'Could not be categorised','error':'Errors',
+ 'faq':'Frequently asked questions (older system)',
+ 'rag':'Library information (older system)',
+ 'search':'Catalogue search (older system)',
+ 'database':'Database recommendation (older system)',
+ 'agent':'Assistant reply (older system)',
+ 'clarify':'Clarifying question (older system)',
+ 'search_recovery':'Search retry (older system)',
+ 'llm_general':'General questions (older system)',
+ 'llm_general_confirmed':'General questions (older system)',
+ 'llm_library':'Library questions (older system)'
+}};
+const MODEL_LABELS={{gpt:'ChatGPT (GPT-4o-mini)',claude:'Claude','n/a':'Not applicable'}};
+const WEEKDAYS=['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
+
+function labelFor(code){{
+ if(INTENT_LABELS[code]) return INTENT_LABELS[code];
+ const base=String(code||'').split(':')[0];
+ if(INTENT_LABELS[base]) return INTENT_LABELS[base];
+ return base?base.replace(/_/g,' ').replace(/\\b\\w/g,c=>c.toUpperCase()):'Other';
+}}
+function speedBand(sec){{
+ if(!sec||sec<0.5) return 'Instant';
+ if(sec<3) return 'Fast';
+ if(sec<8) return 'Moderate';
+ return 'Slower (searches external databases)';
+}}
+function fmtDate(s){{ return (s||'').substring(0,10) || '—'; }}
+function pct(n,total){{ return total?Math.round(n*1000/total)/10:0; }}
+function bar(p){{
+ return '';
+}}
+
+let REPORT_DATA=null;
+
+async function fetchReport(){{
+ const days=document.getElementById('repPeriod').value;
+ const res=await cfFetch('/analytics/report?days='+days);
+ REPORT_DATA=res.data;
+ return REPORT_DATA;
+}}
+
+function reportOverview(d){{
+ const t=d.totals||{{}};
+ const prev=t.previous_period_queries||0;
+ const change=prev?Math.round(((t.queries-prev)/prev)*100):null;
+ const perDay=t.active_days?Math.round(t.queries/t.active_days*10)/10:0;
+ const trend=change===null?'no comparable earlier period '
+ :(change>=0?'up '+change+'% ':'down '+Math.abs(change)+'% ')
+ +' compared with the previous period';
+ return `1. Usage overview
+
+ LibBee answered ${{t.queries||0}} questions during this period,
+ across ${{t.active_days||0}} days when it was used
+ (about ${{perDay}} questions per day on those days). Usage was ${{trend}}.
+ First question in this period: ${{fmtDate(t.first_query)}} ·
+ most recent: ${{fmtDate(t.last_query)}} .
+ ${{t.busiest_day?'Busiest single day: '+t.busiest_day.day+' with '+t.busiest_day.count+' questions.':''}}
+ ${{t.blocked||0}} were misuse attempts the assistant safely refused.
+ ${{t.unanswered_legacy?' '+t.unanswered_legacy+' questions from the older system (before April 2026) were recorded as having no matching guide content.':''}}
+ See report 5 for questions the nightly review judged unanswered.
+
`;
+}}
+
+function reportTopics(d){{
+ const total=(d.totals||{{}}).queries||0;
+ const grouped={{}};
+ (d.by_intent||[]).forEach(r=>{{
+ const l=labelFor(r.k);
+ if(!grouped[l]) grouped[l]={{c:0,t:0,n:0}};
+ grouped[l].c+=r.c;
+ if(r.avg_time){{ grouped[l].t+=r.avg_time*r.c; grouped[l].n+=r.c; }}
+ }});
+ const rows=Object.entries(grouped).sort((a,b)=>b[1].c-a[1].c).map(([l,v])=>{{
+ const p=pct(v.c,total); const avg=v.n?v.t/v.n:0;
+ return `${{l}} ${{v.c}}
+ ${{bar(p)}} ${{p}}%
+ ${{speedBand(avg)}} `;
+ }}).join('');
+ return `2. What people ask about
+ Every question is sorted into a category automatically. This is what people came for.
+ Type of question Questions Share Typical speed ${{rows||'No data. '}}
`;
+}}
+
+function reportWhen(d){{
+ const hours=d.hourly||[]; const maxH=Math.max(1,...hours.map(h=>h.c));
+ const hourRows=hours.map(h=>{{
+ const hr=parseInt(h.h,10);
+ const uae=(hr+4)%24;
+ const ampm=(uae%12===0?12:uae%12)+(uae<12?' AM':' PM');
+ return `${{ampm}} ${{bar(h.c/maxH*100)}} ${{h.c}} `;
+ }}).join('');
+ const wd=d.weekday||[]; const maxW=Math.max(1,...wd.map(w=>w.c));
+ const busiestW=wd.slice().sort((a,b)=>b.c-a.c)[0];
+ const wdRows=wd.map(w=>`${{WEEKDAYS[parseInt(w.d,10)]||w.d}} ${{bar(w.c/maxW*100)}} ${{w.c}} `).join('');
+ return `3. When people use it
+ Times shown in UAE local time. Useful for deciding when human help should be available.
+ ${{busiestW?'Busiest day of the week: '+(WEEKDAYS[parseInt(busiestW.d,10)]||'')+' .':''}}
+
+
By hour of day ${{hourRows||'No data. '}}
+
+
`;
+}}
+
+function reportSpeed(d){{
+ const t=d.totals||{{}};
+ const bands={{}};
+ (d.by_intent||[]).forEach(r=>{{
+ if(!r.avg_time) return;
+ const b=speedBand(r.avg_time);
+ bands[b]=(bands[b]||0)+r.c;
+ }});
+ const totalTimed=Object.values(bands).reduce((a,b)=>a+b,0);
+ const rows=Object.entries(bands).sort((a,b)=>b[1]-a[1]).map(([b,c])=>
+ `${{b}} ${{c}} ${{bar(pct(c,totalTimed))}} ${{pct(c,totalTimed)}}% `).join('');
+ const slowest=(d.by_intent||[]).filter(r=>r.avg_time).sort((a,b)=>b.avg_time-a.avg_time).slice(0,5)
+ .map(r=>`${{labelFor(r.k)}} ${{r.avg_time.toFixed(1)}} seconds `).join('');
+ return `4. How fast it answers
+ Average answer time across all questions:
+ ${{(t.avg_response_time||0).toFixed(1)}} seconds .
+ Questions answered from our own guides are near-instant; questions that search
+ outside databases take longer because they wait for those services.
+
+
Answer speed
+
Speed Questions Share ${{rows||'No timing data. '}}
+
Slowest categories
+
Type of question Average ${{slowest||'No data. '}}
+
`;
+}}
+
+function reportGaps(d){{
+ const reps=d.daily_reports||[];
+ const clusters=[]; const lows=[]; let notAnswered=0; let reviewed=0; let llmErr=null;
+ reps.forEach(r=>{{
+ (r.kb_gap_clusters||[]).forEach(c=>clusters.push(Object.assign({{}},c,{{date:r.report_date}})));
+ (r.low_relevance_samples||[]).forEach(x=>lows.push(Object.assign({{}},x,{{date:r.report_date}})));
+ const st=r.stats||{{}};
+ notAnswered+=(st.not_answered||0); reviewed+=(st.graded||0);
+ if(st.llm_error) llmErr=st.llm_error;
+ }});
+ const cRows=clusters.sort((a,b)=>(b.count||0)-(a.count||0)).slice(0,15).map(c=>
+ `${{c.label||'(unlabelled)'}} ${{c.count||0}}
+ ${{(c.sample_questions||[]).slice(0,2).join(' | ')}} `).join('');
+ const lRows=lows.slice(0,15).map(x=>
+ `${{(x.question||'').substring(0,90)}} ${{x.score}}
+ ${{x.reason||''}} `).join('');
+ let note='';
+ if(!reps.length){{
+ note=''
+ +'The nightly review has not produced results yet. It runs at 2am UAE and examines the '
+ +'previous day’s questions, so results appear the morning after a day with activity.
';
+ }} else if(llmErr){{
+ note=''
+ +'The most recent review could not complete: '+llmErr+'
';
+ }}
+ return `5. Questions we could not answer well
+
+ Each night an automated reviewer reads the previous day’s questions and answers and
+ judges two things: whether the question was actually answered, and how well.
+ Over the last ${{reps.length}} review(s), ${{reviewed}} answers were examined and
+ ${{notAnswered}} were found not to answer the question — usually because
+ the library guides contain nothing on that topic. Those become the themes below.
+ ${{note}}
+ Themes worth adding to the guides
+ Theme Times asked Examples ${{cRows||'Nothing flagged. '}}
+ Answers judged weak on review
+ Question Score (0–1) Reason ${{lRows||'Nothing flagged. '}}
`;
+}}
+
+function reportHealth(d){{
+ const runs=d.canary||[];
+ const ok=runs.filter(r=>r.ok).length;
+ const rows=runs.map(r=>`${{(r.run_at||'').substring(0,16)}}
+ ${{r.ok?'Working ':'Failed '}}
+ ${{(r.question||'').substring(0,80)}}
+ ${{r.latency_ms!=null?(r.latency_ms/1000).toFixed(1)+'s':''}} `).join('');
+ return `6. Service health
+ Each night the system asks itself a real question and checks it gets a
+ sensible answer. These checks are not counted in the usage figures above.
+ ${{ok}} of the last ${{runs.length||0}} daily checks passed.
+ Check Result Question asked Time taken ${{rows||'No checks recorded yet. '}}
`;
+}}
+
+const REPORTS={{overview:reportOverview,topics:reportTopics,when:reportWhen,
+ speed:reportSpeed,gaps:reportGaps,health:reportHealth}};
+
+async function runReport(){{
+ const out=document.getElementById('reportOut');
+ out.innerHTML='Preparing report…
';
+ try{{
+ const d=await fetchReport();
+ const kind=document.getElementById('repKind').value;
+ const periodTxt=document.getElementById('repPeriod').selectedOptions[0].text;
+ let body;
+ if(kind==='all') body=Object.values(REPORTS).map(f=>f(d)).join(' ');
+ else body=REPORTS[kind](d);
+ out.innerHTML=''
+ +'KU Library — LibBee report · '+periodTxt+' · generated '+new Date().toLocaleString()+'
'+body+'
';
+ }}catch(e){{
+ out.innerHTML='Could not load report: '+cfEscape(e.message)+' ';
+ }}
+}}
+
+function printReport(){{
+ const el=document.getElementById('reportBody');
+ if(!el){{ alert('Show a report first.'); return; }}
+ const w=window.open('','_blank');
+ if(!w){{ alert('Allow popups to print this report.'); return; }}
+ w.document.write('LibBee report '
+ +el.innerHTML+'');
+ w.document.close(); w.focus(); w.print();
+}}
+
+function downloadCsv(){{
+ if(!REPORT_DATA){{ alert('Show a report first.'); return; }}
+ const rows=[['Type of question','Questions','Share %','Average seconds']];
+ const total=(REPORT_DATA.totals||{{}}).queries||0;
+ const grouped={{}};
+ (REPORT_DATA.by_intent||[]).forEach(r=>{{
+ const l=labelFor(r.k);
+ if(!grouped[l]) grouped[l]={{c:0,t:0,n:0}};
+ grouped[l].c+=r.c;
+ if(r.avg_time){{ grouped[l].t+=r.avg_time*r.c; grouped[l].n+=r.c; }}
+ }});
+ Object.entries(grouped).sort((a,b)=>b[1].c-a[1].c).forEach(([l,v])=>
+ rows.push([l,v.c,pct(v.c,total),v.n?(v.t/v.n).toFixed(2):'']));
+ const csv=rows.map(r=>r.map(x=>'"'+String(x).replace(/"/g,'""')+'"').join(',')).join('\\n');
+ const a=document.createElement('a');
+ a.href=URL.createObjectURL(new Blob([csv],{{type:'text/csv'}}));
+ a.download='libbee-report-'+new Date().toISOString().substring(0,10)+'.csv';
+ a.click();
+}}
+
+async function loadMetrics(){{
+ try{{
+ const r=await fetch('/admin/metrics');
+ const d=await r.json();
+ const intents=d.intents||{{}};
+ const errors=d.errors||{{}};
+ let rows=Object.entries(intents).sort((a,b)=>b[1]-a[1])
+ .map(([k,v])=>`${{k}} ${{v}} intent `).join('');
+ rows+=Object.entries(errors).sort((a,b)=>b[1]-a[1])
+ .map(([k,v])=>`${{k}} ${{v}} error `).join('');
+ document.getElementById('metricsTable').innerHTML=rows
+ ?``
+ :'No session data yet. ';
+ }}catch(e){{document.getElementById('metricsTable').innerHTML='Could not load ';}}
+}}
+
+async function loadRAGInfo(){{
+ try{{
+ const r=await fetch('/admin/rag-status');
+ const d=await r.json();
+ document.getElementById('ragInfo').innerHTML=
+ `Ready: ${{d.ready}} | Chunks: ${{d.chunk_count}} | KB Dir: ${{cfEscape(d.knowledge_dir)}}`;
+ }}catch(e){{document.getElementById('ragInfo').textContent='Could not load RAG info.';}}
+}}
+
+async function saveConfig(){{
+ const keys=['welcome_message','custom_instructions','announcement','max_results','maintenance_mode','maintenance_message'];
+ const settings={{}};
+ keys.forEach(k=>{{const el=document.getElementById('cfg_'+k);if(el)settings[k]=el.value;}});
+ const message=document.getElementById('saveMsg');
+ message.style.display='block';
+ message.textContent='Saving…';
+ try{{
+ const r=await fetch('/admin/config-bulk',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{settings}})}});
+ const data=await r.json();
+ if(!r.ok) throw new Error(typeof data.detail==='string'?data.detail:'Please check the configuration values.');
+ message.style.color='#166534';message.textContent='Configuration saved.';
+ }}catch(error){{message.style.color='#b91c1c';message.textContent='Could not save: '+error.message;}}
+}}
+
+async function rebuildIndex(){{
+ const el=document.getElementById('rebuildMsg');
+ el.innerHTML='Rebuilding… this may take 30–60 seconds ';
+ try{{
+ const r=await fetch('/admin/rebuild',{{method:'POST'}});
+ const d=await r.json();
+ if(d.status==='ok') el.innerHTML=`✅ Done — ${{d.chunks}} chunks rebuilt. `;
+ else el.innerHTML=`Error: ${{cfEscape(d.error||d.detail||'Request failed')}} `;
+ loadRAGInfo();
+ }}catch(e){{el.innerHTML=`Error: ${{cfEscape(e.message)}} `;}}
+}}
+
+async function loadStatus(){{
+ const el=document.getElementById('sysStatus');
+ try{{
+ const r=await fetch('/admin/status');
+ const d=await r.json();
+ el.innerHTML=`
+
+
${{d.rag_ready?'✅':'⚠️'}}
RAG (${{d.rag_chunks||0}} chunks)
+
${{d.openai?'✅':'❌'}}
OpenAI API
+
${{d.anthropic?'✅':'—'}}
Anthropic API
+
${{d.primo?'✅':'❌'}}
PRIMO API
+
${{d.kb_files||0}}
KB Files
+
${{Math.floor((d.uptime_secs||0)/60)}}m
Uptime
+
${{d.mem_used_mb||0}}MB
Memory (${{d.mem_pct||0}}%)
+
${{d.maintenance?'ON':'OFF'}}
Maintenance
+
+ Last checked: ${{new Date().toLocaleTimeString()}}
`;
+ }}catch(e){{
+ el.innerHTML='❌ Cannot reach server: '+cfEscape(e.message)+' ';
+ }}
+}}
+
+loadStats();
+loadCFAnalytics();
+loadRecentQueries();
+document.getElementById('repRun').onclick=runReport;
+document.getElementById('repPrint').onclick=printReport;
+document.getElementById('repCsv').onclick=downloadCsv;
+runReport();
+loadMetrics();
+loadRAGInfo();
+loadStatus();
+setInterval(loadStatus,30000);
+setInterval(loadCFAnalytics,60000);
+setInterval(loadRecentQueries,60000);
+
+