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
-
- -
-

📊 Analytics

-
Loading…
-
- -
-

⚙️ Bot Configuration

- - - - - - - - - - - - - -
✅ Saved!
-
- -
-

🔄 Index Management

-

Rebuild the FAISS + BM25 hybrid index from knowledge files in the KB directory.

-
Loading RAG status…
- -
-
- -
-

📋 Request Metrics

-
Loading…
-
- -
-

📈 Cloudflare Analytics (Persistent)

-
Loading…
-
-
- -
-

📋 Recent Queries (Cloudflare D1)

-
Loading…
-
- -
-

📊 Reports

-
- - - - - - - -
-
Choose a report and click "Show report".
-
- -
-

🔌 System Status

-
Loading…
-
- -
- -""") - - -# ── 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.today||0}}
Today
+
${{d.week||0}}
This Week
+
${{(d.avg_time||0).toFixed(2)}}s
Avg Response
+
${{d.errors||0}}
Errors
+ `; + const tools=(d.tools||[]).slice(0,15); + let rows=tools.map(t=>`${{t.tool_used||"unknown"}}${{t.c}}`).join(''); + document.getElementById('cfToolsTable').innerHTML=rows + ?`${{rows}}
Intent:ToolCount
`:''; + }}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 + ?`${{rows}}
TimeQuestionToolModelTime#
` + :'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.

+
${{rows||''}}
Type of questionQuestionsShareTypical speed
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.
+

By day of week

${{wdRows||''}}
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

+ ${{rows||''}}
SpeedQuestionsShare
No timing data.
+

Slowest categories

+ ${{slowest||''}}
Type of questionAverage
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

+
${{cRows||''}}
ThemeTimes askedExamples
Nothing flagged.
+

Answers judged weak on review

+
${{lRows||''}}
QuestionScore (0–1)Reason
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.

+
${{rows||''}}
CheckResultQuestion askedTime taken
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 + ?`${{rows}}
KeyCountType
` + :'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); + +""") + + +# ── API Endpoints ────────────────────────────────────────────────────────────── + @router.get("/public-config") async def public_config(): - """No auth — returns public fields for the chat frontend.""" - try: - cfg = _load_config() - except Exception: - cfg = {} - return { - "welcome_message": cfg.get("welcome_message", ""), - "announcement": cfg.get("announcement", ""), - "maintenance_mode": cfg.get("maintenance_mode", False), - "maintenance_message": cfg.get("maintenance_message", ""), + """No auth — returns public fields for the chat frontend.""" + try: + cfg = _load_config() + except Exception: + cfg = get_settings().runtime_defaults + return { + "welcome_message": str(cfg.get("welcome_message", ""))[:500], + "announcement": str(cfg.get("announcement", ""))[:500], + "maintenance_mode": bool(cfg.get("maintenance_mode", False)), + "maintenance_message": str(cfg.get("maintenance_message", ""))[:300], } @@ -935,289 +995,263 @@ async def worker_browser_token(request: Request): "expires_at": expires_at, "scope": "analytics:read", } - - -@router.get("/config") -async def get_config(request: Request): - _require_session(request) - return _load_config() - - -@router.post("/config") + + +@router.get("/config") +async def get_config(request: Request): + _require_session(request) + return _load_config() + + +@router.post("/config") async def update_config(req: ConfigUpdate, request: Request): - _require_session(request) - allowed = {"max_results", "maintenance_mode", "welcome_message", - "custom_instructions", "announcement", "maintenance_message"} - if req.key not in allowed: - raise HTTPException(status_code=400, - detail=f"Unsupported key. Allowed: {sorted(allowed)}") - data = _config_store().load() - value: Any = req.value - if req.key == "max_results": - value = max(1, min(int(req.value), 20)) - elif req.key == "maintenance_mode": - value = str(req.value).lower() in {"1", "true", "yes", "on"} - data[req.key] = value - _config_store().save(data) - return {"status": "ok", "updated": req.key, "value": value} - - -@router.post("/config-bulk") + _require_session(request) + cleaned = _validated_config_values({req.key: req.value}) + data = _config_store().load() + data.update(cleaned) + _config_store().save(data) + return {"status": "ok", "updated": req.key, "value": cleaned[req.key]} + + +@router.post("/config-bulk") async def update_config_bulk(req: ConfigBulkUpdate, request: Request): _require_session(request) + # Validate everything before writing; invalid values used to be skipped + # silently even while the dashboard displayed "Saved". + cleaned = _validated_config_values(req.settings) data = _config_store().load() - allowed = {"max_results", "maintenance_mode", "welcome_message", - "custom_instructions", "announcement", "maintenance_message"} - for key, value in req.settings.items(): - if key not in allowed: - continue - if key == "max_results": - try: - data[key] = max(1, min(int(value), 20)) - except (ValueError, TypeError): - pass - elif key == "maintenance_mode": - data[key] = str(value).lower() in {"1", "true", "yes", "on"} - else: - data[key] = str(value)[:5000] + data.update(cleaned) _config_store().save(data) - return {"status": "ok", "updated": sorted(set(req.settings) & allowed)} - - -# ── RAG Management Endpoints ─────────────────────────────────────────────────── - -@router.get("/rag-status") -async def rag_status(request: Request): - _require_session(request) - try: - from app import get_rag_service - return get_rag_service().get_stats() - except Exception as e: - return {"error": str(e), "ready": False} - - -@router.post("/rebuild") -async def rebuild_rag(request: Request): - _require_session(request) - try: - from app import get_rag_service - svc = get_rag_service() - settings = get_settings() - if not settings.openai_api_key: - return {"status": "error", "error": "OPENAI_API_KEY not set"} - svc._kb_hash = "" # force fresh build - await svc.initialize(openai_api_key=settings.openai_api_key) - return {"status": "ok", "chunks": len(svc.bm25_corpus), "ready": svc.is_ready()} - except Exception as e: - return {"status": "error", "error": str(e)} - - -# ── Metrics & Status Endpoints ───────────────────────────────────────────────── - -@router.get("/metrics") -async def metrics(request: Request): - _require_session(request) - try: - from app import get_metrics_service - return get_metrics_service().snapshot() - except Exception as e: - return {"error": str(e)} - - -@router.get("/status") -async def system_status(request: Request): - _require_session(request) - settings = get_settings() - - mem_used_mb = mem_pct = 0 - try: - with open("/sys/fs/cgroup/memory.current") as f: - used = int(f.read().strip()) - with open("/sys/fs/cgroup/memory.max") as f: - val = f.read().strip() - total = int(val) if val != "max" else 16 * 1024 ** 3 - mem_used_mb = round(used / 1024 / 1024) - mem_pct = round(used / total * 100) - except Exception: - try: - with open("/sys/fs/cgroup/memory/memory.usage_in_bytes") as f: - used = int(f.read().strip()) - with open("/sys/fs/cgroup/memory/memory.limit_in_bytes") as f: - total = int(f.read().strip()) - mem_used_mb = round(used / 1024 / 1024) - mem_pct = round(used / total * 100) - except Exception: - pass - - rag_ready = False - rag_chunks = 0 - kb_files = 0 - try: - from app import get_rag_service - svc = get_rag_service() - rag_ready = svc.is_ready() - rag_chunks = len(svc.bm25_corpus) - kb_dir = settings.kb_dir - kb_files = len(list(kb_dir.glob("*.txt"))) if kb_dir.exists() else 0 - except Exception: - pass - - cfg = _load_config() - - return { - "status": "ok", - "rag_ready": rag_ready, - "rag_chunks": rag_chunks, - "kb_files": kb_files, - "openai": bool(settings.openai_api_key), - "anthropic": bool(settings.anthropic_api_key), - "primo": bool(settings.primo_api_key), - "mem_used_mb": mem_used_mb, - "mem_pct": mem_pct, - "uptime_secs": round(time.time() - _START_TIME), - "maintenance": bool(cfg.get("maintenance_mode")), - "version": LIBBEE_VERSION, - } - -# ── Daily KB-gap / relevance reports (generated by the Cloudflare Worker) ───── -# The Worker's daily cron writes one report row per day; these endpoints proxy -# them into the admin dashboard. Session-protected like every admin route, and -# the Worker itself additionally requires the shared bearer token. - -@router.get("/reports") -async def admin_reports(request: Request, days: int = 14): - _require_session(request) - return await _worker_get("/reports", {"days": max(1, min(days, 60))}) - - -@router.post("/reports/{report_id}/reviewed") -async def admin_report_reviewed(report_id: int, request: Request): - _require_session(request) - import httpx as _hx - from src.config import get_settings as _gs - settings = _gs() - worker = (settings.cloudflare_worker_url or "").rstrip("/") - if not worker: - raise HTTPException(status_code=503, detail="CLOUDFLARE_WORKER_URL not configured") - headers = {} - if settings.cloudflare_admin_token: - headers["Authorization"] = f"Bearer {settings.cloudflare_admin_token}" - try: - async with _hx.AsyncClient(timeout=10) as client: - r = await client.post(f"{worker}/reports/{int(report_id)}/reviewed", headers=headers) - return r.json() - except Exception as exc: - raise HTTPException(status_code=502, detail=f"Worker unreachable: {exc}") - -# ── Analytics proxies ──────────────────────────────────────────────────────── -# The dashboard fetches Worker analytics through these session-protected -# proxies so the Worker URL and ADMIN_TOKEN never reach the browser. - -async def _worker_get(path: str, params: Optional[dict] = None): - from src.services.http_client import make_client as _mk - from src.config import get_settings as _gs - settings = _gs() - worker = (settings.cloudflare_worker_url or "").rstrip("/") - if not worker: - raise HTTPException(status_code=503, detail="CLOUDFLARE_WORKER_URL not configured") - headers = {} - if settings.cloudflare_admin_token: - headers["Authorization"] = f"Bearer {settings.cloudflare_admin_token}" - try: + return {"status": "ok", "updated": sorted(cleaned)} + + +# ── RAG Management Endpoints ─────────────────────────────────────────────────── + +@router.get("/rag-status") +async def rag_status(request: Request): + _require_session(request) + try: + from app import get_rag_service + return get_rag_service().get_stats() + except Exception as e: + return {"error": str(e), "ready": False} + + +@router.post("/rebuild") +async def rebuild_rag(request: Request): + _require_session(request) + try: + from app import get_rag_service + svc = get_rag_service() + settings = get_settings() + await svc.initialize(openai_api_key=settings.openai_api_key, force_rebuild=True) + if not svc.is_ready(): + return {"status": "error", "error": "No usable knowledge index. Check knowledge files and server logs.", "ready": False} + return {"status": "ok", "chunks": len(svc.bm25_corpus), "ready": True, + "retrieval_mode": svc.get_stats().get("retrieval_mode", "unknown")} + except Exception as e: + logger.error("Admin index rebuild failed: %s", type(e).__name__) + return {"status": "error", "error": "Index rebuild failed. Check server logs."} + + +# ── Metrics & Status Endpoints ───────────────────────────────────────────────── + +@router.get("/metrics") +async def metrics(request: Request): + _require_session(request) + try: + from app import get_metrics_service + return get_metrics_service().snapshot() + except Exception as e: + return {"error": str(e)} + + +@router.get("/status") +async def system_status(request: Request): + _require_session(request) + settings = get_settings() + + mem_used_mb = mem_pct = 0 + try: + with open("/sys/fs/cgroup/memory.current") as f: + used = int(f.read().strip()) + with open("/sys/fs/cgroup/memory.max") as f: + val = f.read().strip() + total = int(val) if val != "max" else 16 * 1024 ** 3 + mem_used_mb = round(used / 1024 / 1024) + mem_pct = round(used / total * 100) + except Exception: + try: + with open("/sys/fs/cgroup/memory/memory.usage_in_bytes") as f: + used = int(f.read().strip()) + with open("/sys/fs/cgroup/memory/memory.limit_in_bytes") as f: + total = int(f.read().strip()) + mem_used_mb = round(used / 1024 / 1024) + mem_pct = round(used / total * 100) + except Exception: + pass + + rag_ready = False + rag_chunks = 0 + kb_files = 0 + try: + from app import get_rag_service + svc = get_rag_service() + rag_ready = svc.is_ready() + rag_chunks = len(svc.bm25_corpus) + kb_dir = settings.kb_dir + kb_files = len(list(kb_dir.glob("*.txt"))) if kb_dir.exists() else 0 + except Exception: + pass + + cfg = _load_config() + + return { + "status": "ok", + "rag_ready": rag_ready, + "rag_chunks": rag_chunks, + "kb_files": kb_files, + "openai": bool(settings.openai_api_key), + "anthropic": bool(settings.anthropic_api_key), + "primo": bool(settings.primo_api_key), + "mem_used_mb": mem_used_mb, + "mem_pct": mem_pct, + "uptime_secs": round(time.time() - _START_TIME), + "maintenance": bool(cfg.get("maintenance_mode")), + "version": LIBBEE_VERSION, + } + +# ── Daily KB-gap / relevance reports (generated by the Cloudflare Worker) ───── +# The Worker's daily cron writes one report row per day; these endpoints proxy +# them into the admin dashboard. Session-protected like every admin route, and +# the Worker itself additionally requires the shared bearer token. + +@router.get("/reports") +async def admin_reports(request: Request, days: int = 14): + _require_session(request) + return await _worker_get("/reports", {"days": max(1, min(days, 60))}) + + +@router.post("/reports/{report_id}/reviewed") +async def admin_report_reviewed(report_id: int, request: Request): + _require_session(request) + import httpx as _hx + from src.config import get_settings as _gs + settings = _gs() + worker = _clean_worker_url(settings.cloudflare_worker_url) + if not worker: + raise HTTPException(status_code=503, detail="CLOUDFLARE_WORKER_URL not configured") + headers = {} + if settings.cloudflare_admin_token: + headers["Authorization"] = f"Bearer {settings.cloudflare_admin_token}" + try: + async with _hx.AsyncClient(timeout=10) as client: + r = await client.post(f"{worker}/reports/{int(report_id)}/reviewed", headers=headers) + if r.status_code >= 400: + raise HTTPException(status_code=502, detail=f"Worker returned HTTP {r.status_code}") + return r.json() + except Exception as exc: + raise HTTPException(status_code=502, detail="Worker unavailable. Check the configured URL and server logs.") + +# ── Analytics proxies ──────────────────────────────────────────────────────── +# The dashboard fetches Worker analytics through these session-protected +# proxies so the Worker URL and ADMIN_TOKEN never reach the browser. + +async def _worker_get(path: str, params: Optional[dict] = None): + from src.services.http_client import make_client as _mk + from src.config import get_settings as _gs + settings = _gs() + worker = _clean_worker_url(settings.cloudflare_worker_url) + if not worker: + raise HTTPException(status_code=503, detail="CLOUDFLARE_WORKER_URL not configured") + headers = {} + if settings.cloudflare_admin_token: + headers["Authorization"] = f"Bearer {settings.cloudflare_admin_token}" + try: async with _mk(timeout=8) as client: r = await client.get(f"{worker}{path}", params=params or {}, headers=headers) if r.status_code >= 400: raise HTTPException(status_code=502, detail=f"Worker returned HTTP {r.status_code}") return _escape_untrusted_json(r.json()) - except HTTPException: - raise - except Exception as exc: - raise HTTPException(status_code=502, detail=f"Worker unreachable: {exc}") - - -@router.get("/analytics") -async def admin_analytics(request: Request): - _require_session(request) - return await _worker_get("/analytics") - - -@router.get("/analytics/recent") -async def admin_analytics_recent(request: Request): - _require_session(request) - return await _worker_get("/analytics/recent") - -@router.get("/worker-health") -async def admin_worker_health(request: Request): - """Diagnose the backend -> Worker -> D1 chain. - - Every probe runs independently and records its own outcome, including the - exception type, so one failure never hides the rest. Also reports the exact - URL being used (whitespace or quotes in the secret are a common cause). - """ - _require_session(request) - import asyncio as _aio - from src.services.http_client import make_client as _mk - from src.config import get_settings as _gs, LIBBEE_VERSION as _ver - - settings = _gs() - raw = settings.cloudflare_worker_url or "" - worker = raw.strip().strip('"').strip("'").rstrip("/") - token = (settings.cloudflare_admin_token or "").strip() - - out = { - "backend_version": _ver, - "worker_url_configured": bool(worker), - "worker_url_used": worker, - "worker_url_had_whitespace_or_quotes": raw != worker + ("/" if raw.endswith("/") else ""), - "worker_token_configured": bool(token), - "worker_token_length": len(token), - "probes": {}, - "verdict": "", - } - if not worker: - out["verdict"] = "CLOUDFLARE_WORKER_URL is not set in the Space secrets." - return out - - headers = {"Authorization": f"Bearer {token}"} if token else {} - - async def probe(name, path, use_auth=True): - """One probe, its own short timeout, never raises.""" - entry = {"path": path} - try: - async with _mk(timeout=8) as client: - r = await client.get(f"{worker}{path}", - headers=headers if use_auth else {}) - entry["status"] = r.status_code - entry["body"] = r.text[:300] - except Exception as exc: - entry["error_type"] = type(exc).__name__ - entry["error"] = repr(exc)[:200] or type(exc).__name__ - out["probes"][name] = entry - return entry - - # Run all probes concurrently so the endpoint returns in ~6s, not ~48s. - ver, _hours, ana, sch = await _aio.gather( - probe("version", "/version", use_auth=False), - probe("hours", "/hours", use_auth=False), - probe("analytics", "/analytics"), - probe("schema", "/analytics/schema"), - ) - # Optional authenticated POST probe: run the Worker's isolated canary. - if request.query_params.get("test_write") == "1": - entry = {"path": "/canary/run", "method": "POST"} + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=502, detail="Worker unavailable. Check the configured URL and server logs.") + + +@router.get("/analytics") +async def admin_analytics(request: Request): + _require_session(request) + return await _worker_get("/analytics") + + +@router.get("/analytics/recent") +async def admin_analytics_recent(request: Request): + _require_session(request) + return await _worker_get("/analytics/recent") + +@router.get("/worker-health") +async def admin_worker_health(request: Request): + """Diagnose the backend -> Worker -> D1 chain. + + Every probe runs independently and records its own outcome, including the + exception type, so one failure never hides the rest. Also reports the exact + URL being used (whitespace or quotes in the secret are a common cause). + """ + _require_session(request) + import asyncio as _aio + from src.services.http_client import make_client as _mk + from src.config import get_settings as _gs, LIBBEE_VERSION as _ver + + settings = _gs() + raw = settings.cloudflare_worker_url or "" + worker = _clean_worker_url(raw) + token = (settings.cloudflare_admin_token or "").strip() + + out = { + "backend_version": _ver, + "worker_url_configured": bool(worker), + "worker_url_used": worker, + "worker_url_had_whitespace_or_quotes": raw != worker + ("/" if raw.endswith("/") else ""), + "worker_token_configured": bool(token), + "worker_token_length": len(token), + "probes": {}, + "verdict": "", + } + if not worker: + out["verdict"] = "CLOUDFLARE_WORKER_URL is not set in the Space secrets." + return out + + headers = {"Authorization": f"Bearer {token}"} if token else {} + + async def probe(name, path, use_auth=True): + """One probe, its own short timeout, never raises.""" + entry = {"path": path} try: - async with _mk(timeout=190) as client: - r = await client.post(f"{worker}/canary/run", headers=headers) - entry["status"] = r.status_code - entry["body"] = r.text[:200] - except Exception as exc: - entry["error_type"] = type(exc).__name__ - entry["error"] = repr(exc)[:200] or type(exc).__name__ - out["probes"]["canary_write"] = entry - - # Interpretation - if "error_type" in ver and "error_type" in out["probes"]["hours"]: - et = ver.get("error_type") + async with _mk(timeout=8) as client: + r = await client.get(f"{worker}{path}", + headers=headers if use_auth else {}) + entry["status"] = r.status_code + entry["body"] = r.text[:300] + except Exception as exc: + entry["error_type"] = type(exc).__name__ + entry["error"] = repr(exc)[:200] or type(exc).__name__ + out["probes"][name] = entry + return entry + + # Run all probes concurrently so the endpoint returns in ~6s, not ~48s. + ver, _hours, ana, sch = await _aio.gather( + probe("version", "/version", use_auth=False), + probe("hours", "/hours", use_auth=False), + probe("analytics", "/analytics"), + probe("schema", "/analytics/schema"), + ) + if request.query_params.get("test_write") == "1": + out["canary_note"] = "Use POST /admin/canary-run to run a canary. This diagnostic GET is read-only." + + # Interpretation + if "error_type" in ver and "error_type" in out["probes"]["hours"]: + et = ver.get("error_type") if et in ("ConnectTimeout", "ConnectError", "ReadTimeout"): out["verdict"] = ( f"{et}: the HF server cannot open a connection to workers.dev. " @@ -1226,68 +1260,93 @@ async def admin_worker_health(request: Request): f"calls LibCal directly. Verify the dashboard rather than using " f"this server-to-server probe as the acceptance test." ) - else: - out["verdict"] = ( - f"No HTTP response from the Worker ({et}). Check that " - f"worker_url_used above is exactly your workers.dev address." - ) - elif ver.get("status") == 404: - out["verdict"] = ( + else: + out["verdict"] = ( + f"No HTTP response from the Worker ({et}). Check that " + f"worker_url_used above is exactly your workers.dev address." + ) + elif ver.get("status") == 404: + out["verdict"] = ( "Worker responds but has no /version route. Deploy the v4 secure gateway." - ) - elif ana.get("status") == 401: - out["verdict"] = ( - "Analytics returns 401. CLOUDFLARE_ADMIN_TOKEN does not match the " - "Worker's ADMIN_TOKEN (check for trailing spaces in either)." - ) - elif ana.get("status") == 200: - migrated = None - if sch.get("status") == 200: - try: - import json as _json - migrated = (_json.loads(sch["body"]) or {}).get("migration_applied") - except Exception: - migrated = None - if migrated is False: - out["verdict"] = ("Connected, but the D1 migration is not applied " - "(answer_excerpt/source_ids missing).") - else: - out["verdict"] = "OK - backend, Worker and D1 analytics are connected." - else: - out["verdict"] = (f"Unexpected analytics status {ana.get('status')}. " - f"See probes for details.") - return out - -@router.get("/log-status") -async def admin_log_status(request: Request): - """Outcome of recent analytics writes, without needing container logs. - - 'recent_attempts' is newest-first. Read it like this: - ok -> the write reached the Worker (HTTP 2xx) - throttled -> HTTP 403/429: the host is being rate-limited by Cloudflare - unreachable -> no HTTP response; exception type is in 'detail' - rejected_401 -> CLOUDFLARE_ADMIN_TOKEN does not match ADMIN_TOKEN - Buffered rows are retried automatically on later queries. - """ - _require_session(request) - from src.agentcore.orchestrator import analytics_log_status - from src.config import get_settings as _gs - st = _gs() - data = analytics_log_status() - data["worker_url_configured"] = bool(st.cloudflare_worker_url) - data["worker_token_configured"] = bool(st.cloudflare_admin_token) - if not data["recent_attempts"]: - data["hint"] = ("No write attempts recorded since the last restart. " - "Send a query through LibBee, then reload this page.") - return data - -@router.get("/canary-status") -async def admin_canary_status(request: Request): - """Last 14 daily canary runs (the Worker's scheduled health probe). - - Each run is one real question through the full pipeline, chosen from a - fixed 14-question pool rotating by day-of-year. Canary traffic is excluded - from analytics, so these runs never appear in the D1 usage data. - """ - _require_session(request) + ) + elif ana.get("status") == 401: + out["verdict"] = ( + "Analytics returns 401. CLOUDFLARE_ADMIN_TOKEN does not match the " + "Worker's ADMIN_TOKEN (check for trailing spaces in either)." + ) + elif ana.get("status") == 200: + migrated = None + if sch.get("status") == 200: + try: + import json as _json + migrated = (_json.loads(sch["body"]) or {}).get("migration_applied") + except Exception: + migrated = None + if migrated is False: + out["verdict"] = ("Connected, but the D1 migration is not applied " + "(answer_excerpt/source_ids missing).") + else: + out["verdict"] = "OK - backend, Worker and D1 analytics are connected." + else: + out["verdict"] = (f"Unexpected analytics status {ana.get('status')}. " + f"See probes for details.") + return out + + +@router.post("/canary-run") +async def admin_canary_run(request: Request): + """Explicit admin action; never start a paid model request from a GET.""" + _require_session(request) + settings = get_settings() + worker = _clean_worker_url(settings.cloudflare_worker_url) + if not worker or not settings.cloudflare_admin_token: + raise HTTPException(status_code=503, detail="Worker admin access is not configured") + from src.services.http_client import make_client + try: + async with make_client(timeout=190, retries=0) as client: + response = await client.post( + f"{worker}/canary/run", + headers={"Authorization": f"Bearer {settings.cloudflare_admin_token}"}, + ) + if response.status_code >= 400: + raise HTTPException(status_code=502, detail=f"Worker returned HTTP {response.status_code}") + return response.json() + except HTTPException: + raise + except Exception as exc: + logger.warning("Worker canary request failed: %s", type(exc).__name__) + raise HTTPException(status_code=502, detail="Worker canary unavailable") from exc + +@router.get("/log-status") +async def admin_log_status(request: Request): + """Outcome of recent analytics writes, without needing container logs. + + 'recent_attempts' is newest-first. Read it like this: + ok -> the write reached the Worker (HTTP 2xx) + throttled -> HTTP 403/429: the host is being rate-limited by Cloudflare + unreachable -> no HTTP response; exception type is in 'detail' + rejected_401 -> CLOUDFLARE_ADMIN_TOKEN does not match ADMIN_TOKEN + Buffered rows are retried automatically on later queries. + """ + _require_session(request) + from src.agentcore.orchestrator import analytics_log_status + from src.config import get_settings as _gs + st = _gs() + data = analytics_log_status() + data["worker_url_configured"] = bool(st.cloudflare_worker_url) + data["worker_token_configured"] = bool(st.cloudflare_admin_token) + if not data["recent_attempts"]: + data["hint"] = ("No write attempts recorded since the last restart. " + "Send a query through LibBee, then reload this page.") + return data + +@router.get("/canary-status") +async def admin_canary_status(request: Request): + """Last 14 daily canary runs (the Worker's scheduled health probe). + + Each run is one real question through the full pipeline, chosen from a + fixed 14-question pool rotating by day-of-year. Canary traffic is excluded + from analytics, so these runs never appear in the D1 usage data. + """ + _require_session(request) return await _worker_get("/canary/status")