Update src/api/admin.py
Browse files- src/api/admin.py +300 -13
src/api/admin.py
CHANGED
|
@@ -359,6 +359,33 @@ tr:last-child td{{border-bottom:none}}
|
|
| 359 |
<div id="recentTable" style="overflow-x:auto"><div style="color:#6b7280;font-size:.84rem">Loading…</div></div>
|
| 360 |
</div>
|
| 361 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
<div class="card">
|
| 363 |
<h2>🔌 System Status</h2>
|
| 364 |
<div id="sysStatus"><div style="color:#6b7280;font-size:.84rem">Loading…</div></div>
|
|
@@ -446,6 +473,262 @@ async function loadRecentQueries(){{
|
|
| 446 |
}}
|
| 447 |
}}
|
| 448 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
async function loadMetrics(){{
|
| 450 |
try{{
|
| 451 |
const r=await fetch('/admin/metrics');
|
|
@@ -516,6 +799,10 @@ async function loadStatus(){{
|
|
| 516 |
loadStats();
|
| 517 |
loadCFAnalytics();
|
| 518 |
loadRecentQueries();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
loadMetrics();
|
| 520 |
loadRAGInfo();
|
| 521 |
loadStatus();
|
|
@@ -888,16 +1175,16 @@ async def admin_log_status(request: Request):
|
|
| 888 |
if not data["recent_attempts"]:
|
| 889 |
data["hint"] = ("No write attempts recorded since the last restart. "
|
| 890 |
"Send a query through LibBee, then reload this page.")
|
| 891 |
-
return data
|
| 892 |
-
|
| 893 |
-
@router.get("/canary-status")
|
| 894 |
-
async def admin_canary_status(request: Request):
|
| 895 |
-
"""Last 14 daily canary runs (the Worker's scheduled health probe).
|
| 896 |
-
|
| 897 |
-
Each run is one real question through the full pipeline, chosen from a
|
| 898 |
-
fixed 14-question pool rotating by day-of-year. Canary traffic is excluded
|
| 899 |
-
from analytics, so these runs never appear in the D1 usage data.
|
| 900 |
-
"""
|
| 901 |
-
_require_session(request)
|
| 902 |
-
return await _worker_get("/canary/status")
|
| 903 |
-
|
|
|
|
| 359 |
<div id="recentTable" style="overflow-x:auto"><div style="color:#6b7280;font-size:.84rem">Loading…</div></div>
|
| 360 |
</div>
|
| 361 |
|
| 362 |
+
<div class="card">
|
| 363 |
+
<h2>📊 Reports</h2>
|
| 364 |
+
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
|
| 365 |
+
<label style="font-size:.82rem">Period:</label>
|
| 366 |
+
<select id="repPeriod" style="padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
|
| 367 |
+
<option value="7">Last 7 days</option>
|
| 368 |
+
<option value="30" selected>Last 30 days</option>
|
| 369 |
+
<option value="90">Last 90 days</option>
|
| 370 |
+
<option value="0">All time</option>
|
| 371 |
+
</select>
|
| 372 |
+
<label style="font-size:.82rem;margin-left:8px">Report:</label>
|
| 373 |
+
<select id="repKind" style="padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
|
| 374 |
+
<option value="overview">1. Usage overview</option>
|
| 375 |
+
<option value="topics">2. What people ask about</option>
|
| 376 |
+
<option value="when">3. When people use it</option>
|
| 377 |
+
<option value="speed">4. How fast it answers</option>
|
| 378 |
+
<option value="gaps">5. Questions we could not answer well</option>
|
| 379 |
+
<option value="health">6. Service health</option>
|
| 380 |
+
<option value="all">Everything (printable summary)</option>
|
| 381 |
+
</select>
|
| 382 |
+
<button class="btn" id="repRun" style="padding:6px 12px">Show report</button>
|
| 383 |
+
<button class="btn" id="repPrint" style="padding:6px 12px">Print / PDF</button>
|
| 384 |
+
<button class="btn" id="repCsv" style="padding:6px 12px">Download CSV</button>
|
| 385 |
+
</div>
|
| 386 |
+
<div id="reportOut"><div style="color:#6b7280;font-size:.84rem">Choose a report and click "Show report".</div></div>
|
| 387 |
+
</div>
|
| 388 |
+
|
| 389 |
<div class="card">
|
| 390 |
<h2>🔌 System Status</h2>
|
| 391 |
<div id="sysStatus"><div style="color:#6b7280;font-size:.84rem">Loading…</div></div>
|
|
|
|
| 473 |
}}
|
| 474 |
}}
|
| 475 |
|
| 476 |
+
// ── Plain-language reporting ────────────────────────────────────────────────
|
| 477 |
+
// Internal routing codes are meaningless to a reader, so every code is mapped
|
| 478 |
+
// to a phrase a librarian or director would recognise. Unknown codes degrade to
|
| 479 |
+
// a title-cased version of the code rather than disappearing.
|
| 480 |
+
const INTENT_LABELS = {{
|
| 481 |
+
'library_info:rag_hybrid_search':'Library information (from our guides)',
|
| 482 |
+
'library_info:libcal_live_hours':'Opening hours',
|
| 483 |
+
'library_info:hours_hardcoded':'Opening hours',
|
| 484 |
+
'library_info:libcal_live_events':'Library events & workshops',
|
| 485 |
+
'library_info:fulltext_chain':'How to get the full text of an article',
|
| 486 |
+
'library_info:database_recommendation':'Which database should I use',
|
| 487 |
+
'library_info:staff_name_match':'Contacting a named staff member',
|
| 488 |
+
'library_info:staff_role_match':'Who to contact for help',
|
| 489 |
+
'library_info:theses_guide':'Finding theses & dissertations',
|
| 490 |
+
'library_info:campus_hardcoded':'Campus & location',
|
| 491 |
+
'library_info':'General library information',
|
| 492 |
+
'search_academic:primo_inline':'Finding articles & books',
|
| 493 |
+
'search_academic:primo_browser_link':'Finding articles & books (opened in catalogue)',
|
| 494 |
+
'search_academic:research_snapshot':'Research starting points',
|
| 495 |
+
'search_academic:follow_up_resolution, both_resources':'Refining an earlier search',
|
| 496 |
+
'search_academic':'Finding articles & books',
|
| 497 |
+
'search_medical:primo_inline':'Medical & health literature',
|
| 498 |
+
'search_medical:primo_browser_link':'Medical & health literature (opened in catalogue)',
|
| 499 |
+
'search_medical':'Medical & health literature',
|
| 500 |
+
'general:web_search':'General questions (answered from the web)',
|
| 501 |
+
'general_recent:web_search':'Current events & news',
|
| 502 |
+
'general_topic':'General topic questions','general_recent':'Current events & news',
|
| 503 |
+
'general':'General questions',
|
| 504 |
+
'social_greeting:libbee_casual':'Greetings & small talk',
|
| 505 |
+
'social_greeting:':'Greetings & small talk','social_greeting':'Greetings & small talk',
|
| 506 |
+
'sensitive_blocked:sensitive_intent_guard':'Sensitive topics (safely redirected)',
|
| 507 |
+
'blocked:injection_guard':'Blocked misuse attempts',
|
| 508 |
+
'not_in_kb':'No information available in our guides',
|
| 509 |
+
'unknown':'Could not be categorised','error':'Errors',
|
| 510 |
+
'faq':'Frequently asked questions (older system)',
|
| 511 |
+
'rag':'Library information (older system)',
|
| 512 |
+
'search':'Catalogue search (older system)',
|
| 513 |
+
'database':'Database recommendation (older system)',
|
| 514 |
+
'agent':'Assistant reply (older system)',
|
| 515 |
+
'clarify':'Clarifying question (older system)',
|
| 516 |
+
'search_recovery':'Search retry (older system)',
|
| 517 |
+
'llm_general':'General questions (older system)',
|
| 518 |
+
'llm_general_confirmed':'General questions (older system)',
|
| 519 |
+
'llm_library':'Library questions (older system)'
|
| 520 |
+
}};
|
| 521 |
+
const MODEL_LABELS={{gpt:'ChatGPT (GPT-4o-mini)',claude:'Claude','n/a':'Not applicable'}};
|
| 522 |
+
const WEEKDAYS=['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
|
| 523 |
+
|
| 524 |
+
function labelFor(code){{
|
| 525 |
+
if(INTENT_LABELS[code]) return INTENT_LABELS[code];
|
| 526 |
+
const base=String(code||'').split(':')[0];
|
| 527 |
+
if(INTENT_LABELS[base]) return INTENT_LABELS[base];
|
| 528 |
+
return base?base.replace(/_/g,' ').replace(/\\b\\w/g,c=>c.toUpperCase()):'Other';
|
| 529 |
+
}}
|
| 530 |
+
function speedBand(sec){{
|
| 531 |
+
if(!sec||sec<0.5) return 'Instant';
|
| 532 |
+
if(sec<3) return 'Fast';
|
| 533 |
+
if(sec<8) return 'Moderate';
|
| 534 |
+
return 'Slower (searches external databases)';
|
| 535 |
+
}}
|
| 536 |
+
function fmtDate(s){{ return (s||'').substring(0,10) || '—'; }}
|
| 537 |
+
function pct(n,total){{ return total?Math.round(n*1000/total)/10:0; }}
|
| 538 |
+
function bar(p){{
|
| 539 |
+
return '<div style="background:#e5e7eb;border-radius:4px;height:8px;width:110px;display:inline-block;vertical-align:middle">'
|
| 540 |
+
+'<div style="background:#003366;height:8px;border-radius:4px;width:'+Math.max(2,Math.round(p))+'%"></div></div>';
|
| 541 |
+
}}
|
| 542 |
+
|
| 543 |
+
let REPORT_DATA=null;
|
| 544 |
+
|
| 545 |
+
async function fetchReport(){{
|
| 546 |
+
const days=document.getElementById('repPeriod').value;
|
| 547 |
+
const res=await cfFetch('/analytics/report?days='+days);
|
| 548 |
+
REPORT_DATA=res.data;
|
| 549 |
+
return REPORT_DATA;
|
| 550 |
+
}}
|
| 551 |
+
|
| 552 |
+
function reportOverview(d){{
|
| 553 |
+
const t=d.totals||{{}};
|
| 554 |
+
const prev=t.previous_period_queries||0;
|
| 555 |
+
const change=prev?Math.round(((t.queries-prev)/prev)*100):null;
|
| 556 |
+
const perDay=t.active_days?Math.round(t.queries/t.active_days*10)/10:0;
|
| 557 |
+
const trend=change===null?'<em>no comparable earlier period</em>'
|
| 558 |
+
:(change>=0?'<span style="color:#047857">up '+change+'%</span>':'<span style="color:#b45309">down '+Math.abs(change)+'%</span>')
|
| 559 |
+
+' compared with the previous period';
|
| 560 |
+
return `<h3 style="margin:6px 0">1. Usage overview</h3>
|
| 561 |
+
<p style="font-size:.9rem;line-height:1.7">
|
| 562 |
+
LibBee answered <strong>${{t.queries||0}} questions</strong> during this period,
|
| 563 |
+
across <strong>${{t.active_days||0}} days</strong> when it was used
|
| 564 |
+
(about <strong>${{perDay}} questions per day</strong> on those days). Usage was ${{trend}}.<br>
|
| 565 |
+
First question in this period: <strong>${{fmtDate(t.first_query)}}</strong> ·
|
| 566 |
+
most recent: <strong>${{fmtDate(t.last_query)}}</strong>.
|
| 567 |
+
${{t.busiest_day?'Busiest single day: <strong>'+t.busiest_day.day+'</strong> with '+t.busiest_day.count+' questions.':''}}<br>
|
| 568 |
+
Of these, <strong>${{t.unanswered||0}}</strong> could not be answered from the library guides
|
| 569 |
+
(${{pct(t.unanswered||0,t.queries||1)}}% — see report 5), and
|
| 570 |
+
<strong>${{t.blocked||0}}</strong> were misuse attempts the assistant safely refused.
|
| 571 |
+
</p>`;
|
| 572 |
+
}}
|
| 573 |
+
|
| 574 |
+
function reportTopics(d){{
|
| 575 |
+
const total=(d.totals||{{}}).queries||0;
|
| 576 |
+
const grouped={{}};
|
| 577 |
+
(d.by_intent||[]).forEach(r=>{{
|
| 578 |
+
const l=labelFor(r.k);
|
| 579 |
+
if(!grouped[l]) grouped[l]={{c:0,t:0,n:0}};
|
| 580 |
+
grouped[l].c+=r.c;
|
| 581 |
+
if(r.avg_time){{ grouped[l].t+=r.avg_time*r.c; grouped[l].n+=r.c; }}
|
| 582 |
+
}});
|
| 583 |
+
const rows=Object.entries(grouped).sort((a,b)=>b[1].c-a[1].c).map(([l,v])=>{{
|
| 584 |
+
const p=pct(v.c,total); const avg=v.n?v.t/v.n:0;
|
| 585 |
+
return `<tr><td>${{l}}</td><td style="white-space:nowrap">${{v.c}}</td>
|
| 586 |
+
<td style="white-space:nowrap">${{bar(p)}} ${{p}}%</td>
|
| 587 |
+
<td>${{speedBand(avg)}}</td></tr>`;
|
| 588 |
+
}}).join('');
|
| 589 |
+
return `<h3 style="margin:6px 0">2. What people ask about</h3>
|
| 590 |
+
<p style="font-size:.86rem;color:#4b5563">Every question is sorted into a category automatically. This is what people came for.</p>
|
| 591 |
+
<div style="overflow-x:auto"><table><thead><tr><th>Type of question</th><th>Questions</th><th>Share</th><th>Typical speed</th></tr></thead><tbody>${{rows||'<tr><td colspan="4">No data.</td></tr>'}}</tbody></table></div>`;
|
| 592 |
+
}}
|
| 593 |
+
|
| 594 |
+
function reportWhen(d){{
|
| 595 |
+
const hours=d.hourly||[]; const maxH=Math.max(1,...hours.map(h=>h.c));
|
| 596 |
+
const hourRows=hours.map(h=>{{
|
| 597 |
+
const hr=parseInt(h.h,10);
|
| 598 |
+
const uae=(hr+4)%24;
|
| 599 |
+
const ampm=(uae%12===0?12:uae%12)+(uae<12?' AM':' PM');
|
| 600 |
+
return `<tr><td style="white-space:nowrap">${{ampm}}</td><td>${{bar(h.c/maxH*100)}} ${{h.c}}</td></tr>`;
|
| 601 |
+
}}).join('');
|
| 602 |
+
const wd=d.weekday||[]; const maxW=Math.max(1,...wd.map(w=>w.c));
|
| 603 |
+
const busiestW=wd.slice().sort((a,b)=>b.c-a.c)[0];
|
| 604 |
+
const wdRows=wd.map(w=>`<tr><td>${{WEEKDAYS[parseInt(w.d,10)]||w.d}}</td><td>${{bar(w.c/maxW*100)}} ${{w.c}}</td></tr>`).join('');
|
| 605 |
+
return `<h3 style="margin:6px 0">3. When people use it</h3>
|
| 606 |
+
<p style="font-size:.86rem;color:#4b5563">Times shown in UAE local time. Useful for deciding when human help should be available.
|
| 607 |
+
${{busiestW?'Busiest day of the week: <strong>'+(WEEKDAYS[parseInt(busiestW.d,10)]||'')+'</strong>.':''}}</p>
|
| 608 |
+
<div style="display:flex;gap:24px;flex-wrap:wrap">
|
| 609 |
+
<div><h4 style="font-size:.85rem;margin:4px 0">By hour of day</h4><table><tbody>${{hourRows||'<tr><td>No data.</td></tr>'}}</tbody></table></div>
|
| 610 |
+
<div><h4 style="font-size:.85rem;margin:4px 0">By day of week</h4><table><tbody>${{wdRows||'<tr><td>No data.</td></tr>'}}</tbody></table></div>
|
| 611 |
+
</div>`;
|
| 612 |
+
}}
|
| 613 |
+
|
| 614 |
+
function reportSpeed(d){{
|
| 615 |
+
const t=d.totals||{{}};
|
| 616 |
+
const bands={{}};
|
| 617 |
+
(d.by_intent||[]).forEach(r=>{{
|
| 618 |
+
if(!r.avg_time) return;
|
| 619 |
+
const b=speedBand(r.avg_time);
|
| 620 |
+
bands[b]=(bands[b]||0)+r.c;
|
| 621 |
+
}});
|
| 622 |
+
const totalTimed=Object.values(bands).reduce((a,b)=>a+b,0);
|
| 623 |
+
const rows=Object.entries(bands).sort((a,b)=>b[1]-a[1]).map(([b,c])=>
|
| 624 |
+
`<tr><td>${{b}}</td><td>${{c}}</td><td>${{bar(pct(c,totalTimed))}} ${{pct(c,totalTimed)}}%</td></tr>`).join('');
|
| 625 |
+
const slowest=(d.by_intent||[]).filter(r=>r.avg_time).sort((a,b)=>b.avg_time-a.avg_time).slice(0,5)
|
| 626 |
+
.map(r=>`<tr><td>${{labelFor(r.k)}}</td><td>${{r.avg_time.toFixed(1)}} seconds</td></tr>`).join('');
|
| 627 |
+
return `<h3 style="margin:6px 0">4. How fast it answers</h3>
|
| 628 |
+
<p style="font-size:.9rem;line-height:1.7">Average answer time across all questions:
|
| 629 |
+
<strong>${{(t.avg_response_time||0).toFixed(1)}} seconds</strong>.
|
| 630 |
+
Questions answered from our own guides are near-instant; questions that search
|
| 631 |
+
outside databases take longer because they wait for those services.</p>
|
| 632 |
+
<div style="display:flex;gap:24px;flex-wrap:wrap">
|
| 633 |
+
<div><h4 style="font-size:.85rem;margin:4px 0">Answer speed</h4>
|
| 634 |
+
<table><thead><tr><th>Speed</th><th>Questions</th><th>Share</th></tr></thead><tbody>${{rows||'<tr><td colspan="3">No timing data.</td></tr>'}}</tbody></table></div>
|
| 635 |
+
<div><h4 style="font-size:.85rem;margin:4px 0">Slowest categories</h4>
|
| 636 |
+
<table><thead><tr><th>Type of question</th><th>Average</th></tr></thead><tbody>${{slowest||'<tr><td colspan="2">No data.</td></tr>'}}</tbody></table></div>
|
| 637 |
+
</div>`;
|
| 638 |
+
}}
|
| 639 |
+
|
| 640 |
+
function reportGaps(d){{
|
| 641 |
+
const t=d.totals||{{}};
|
| 642 |
+
const reps=d.daily_reports||[];
|
| 643 |
+
const clusters=[]; const lows=[];
|
| 644 |
+
reps.forEach(r=>{{
|
| 645 |
+
(r.kb_gap_clusters||[]).forEach(c=>clusters.push({{...c,date:r.report_date}}));
|
| 646 |
+
(r.low_relevance_samples||[]).forEach(x=>lows.push({{...x,date:r.report_date}}));
|
| 647 |
+
}});
|
| 648 |
+
const cRows=clusters.sort((a,b)=>(b.count||0)-(a.count||0)).slice(0,15).map(c=>
|
| 649 |
+
`<tr><td>${{c.label||'(unlabelled)'}}</td><td>${{c.count||0}}</td>
|
| 650 |
+
<td style="color:#6b7280;font-size:.78rem">${{(c.sample_questions||[]).slice(0,2).join(' | ')}}</td></tr>`).join('');
|
| 651 |
+
const lRows=lows.slice(0,15).map(x=>
|
| 652 |
+
`<tr><td>${{(x.question||'').substring(0,90)}}</td><td>${{x.score}}</td>
|
| 653 |
+
<td style="color:#6b7280;font-size:.78rem">${{x.reason||''}}</td></tr>`).join('');
|
| 654 |
+
return `<h3 style="margin:6px 0">5. Questions we could not answer well</h3>
|
| 655 |
+
<p style="font-size:.9rem;line-height:1.7">
|
| 656 |
+
<strong>${{t.unanswered||0}}</strong> questions in this period had no matching information in the
|
| 657 |
+
library guides. Each theme below is a suggestion for content worth adding.
|
| 658 |
+
${{reps.length?'':'<em>The nightly review has not produced results yet; themes appear once it has run.</em>'}}</p>
|
| 659 |
+
<h4 style="font-size:.85rem;margin:8px 0 4px">Themes with missing information</h4>
|
| 660 |
+
<div style="overflow-x:auto"><table><thead><tr><th>Theme</th><th>Times asked</th><th>Examples</th></tr></thead><tbody>${{cRows||'<tr><td colspan="3">Nothing flagged.</td></tr>'}}</tbody></table></div>
|
| 661 |
+
<h4 style="font-size:.85rem;margin:12px 0 4px">Answers judged weak on review</h4>
|
| 662 |
+
<div style="overflow-x:auto"><table><thead><tr><th>Question</th><th>Score (0–1)</th><th>Reason</th></tr></thead><tbody>${{lRows||'<tr><td colspan="3">Nothing flagged.</td></tr>'}}</tbody></table></div>`;
|
| 663 |
+
}}
|
| 664 |
+
|
| 665 |
+
function reportHealth(d){{
|
| 666 |
+
const runs=d.canary||[];
|
| 667 |
+
const ok=runs.filter(r=>r.ok).length;
|
| 668 |
+
const rows=runs.map(r=>`<tr><td style="white-space:nowrap">${{(r.run_at||'').substring(0,16)}}</td>
|
| 669 |
+
<td>${{r.ok?'<span style="color:#047857;font-weight:700">Working</span>':'<span style="color:#dc2626;font-weight:700">Failed</span>'}}</td>
|
| 670 |
+
<td style="max-width:260px">${{(r.question||'').substring(0,80)}}</td>
|
| 671 |
+
<td>${{r.latency_ms!=null?(r.latency_ms/1000).toFixed(1)+'s':''}}</td></tr>`).join('');
|
| 672 |
+
return `<h3 style="margin:6px 0">6. Service health</h3>
|
| 673 |
+
<p style="font-size:.9rem;line-height:1.7">Each night the system asks itself a real question and checks it gets a
|
| 674 |
+
sensible answer. These checks are not counted in the usage figures above.<br>
|
| 675 |
+
<strong>${{ok}} of the last ${{runs.length||0}} daily checks passed.</strong></p>
|
| 676 |
+
<div style="overflow-x:auto"><table><thead><tr><th>Check</th><th>Result</th><th>Question asked</th><th>Time taken</th></tr></thead><tbody>${{rows||'<tr><td colspan="4">No checks recorded yet.</td></tr>'}}</tbody></table></div>`;
|
| 677 |
+
}}
|
| 678 |
+
|
| 679 |
+
const REPORTS={{overview:reportOverview,topics:reportTopics,when:reportWhen,
|
| 680 |
+
speed:reportSpeed,gaps:reportGaps,health:reportHealth}};
|
| 681 |
+
|
| 682 |
+
async function runReport(){{
|
| 683 |
+
const out=document.getElementById('reportOut');
|
| 684 |
+
out.innerHTML='<div style="color:#6b7280;font-size:.84rem">Preparing report…</div>';
|
| 685 |
+
try{{
|
| 686 |
+
const d=await fetchReport();
|
| 687 |
+
const kind=document.getElementById('repKind').value;
|
| 688 |
+
const periodTxt=document.getElementById('repPeriod').selectedOptions[0].text;
|
| 689 |
+
let body;
|
| 690 |
+
if(kind==='all') body=Object.values(REPORTS).map(f=>f(d)).join('<hr style="margin:16px 0;border:none;border-top:1px solid #e5e7eb">');
|
| 691 |
+
else body=REPORTS[kind](d);
|
| 692 |
+
out.innerHTML='<div id="reportBody"><div style="font-size:.78rem;color:#6b7280;margin-bottom:8px">'
|
| 693 |
+
+'KU Library — LibBee report · '+periodTxt+' · generated '+new Date().toLocaleString()+'</div>'+body+'</div>';
|
| 694 |
+
}}catch(e){{
|
| 695 |
+
out.innerHTML='<span style="color:#dc2626">Could not load report: '+e.message+'</span>';
|
| 696 |
+
}}
|
| 697 |
+
}}
|
| 698 |
+
|
| 699 |
+
function printReport(){{
|
| 700 |
+
const el=document.getElementById('reportBody');
|
| 701 |
+
if(!el){{ alert('Show a report first.'); return; }}
|
| 702 |
+
const w=window.open('','_blank');
|
| 703 |
+
w.document.write('<html><head><title>LibBee report</title><style>'
|
| 704 |
+
+'body{{font-family:Segoe UI,system-ui,sans-serif;padding:24px;color:#111}}'
|
| 705 |
+
+'table{{border-collapse:collapse;width:100%;margin:8px 0}}'
|
| 706 |
+
+'th,td{{border:1px solid #ddd;padding:6px 8px;text-align:left;font-size:.85rem}}'
|
| 707 |
+
+'th{{background:#f3f4f6}}h3{{color:#003366}}</style></head><body>'
|
| 708 |
+
+el.innerHTML+'</body></html>');
|
| 709 |
+
w.document.close(); w.focus(); w.print();
|
| 710 |
+
}}
|
| 711 |
+
|
| 712 |
+
function downloadCsv(){{
|
| 713 |
+
if(!REPORT_DATA){{ alert('Show a report first.'); return; }}
|
| 714 |
+
const rows=[['Type of question','Questions','Share %','Average seconds']];
|
| 715 |
+
const total=(REPORT_DATA.totals||{{}}).queries||0;
|
| 716 |
+
const grouped={{}};
|
| 717 |
+
(REPORT_DATA.by_intent||[]).forEach(r=>{{
|
| 718 |
+
const l=labelFor(r.k);
|
| 719 |
+
if(!grouped[l]) grouped[l]={{c:0,t:0,n:0}};
|
| 720 |
+
grouped[l].c+=r.c;
|
| 721 |
+
if(r.avg_time){{ grouped[l].t+=r.avg_time*r.c; grouped[l].n+=r.c; }}
|
| 722 |
+
}});
|
| 723 |
+
Object.entries(grouped).sort((a,b)=>b[1].c-a[1].c).forEach(([l,v])=>
|
| 724 |
+
rows.push([l,v.c,pct(v.c,total),v.n?(v.t/v.n).toFixed(2):'']));
|
| 725 |
+
const csv=rows.map(r=>r.map(x=>'"'+String(x).replace(/"/g,'""')+'"').join(',')).join('\\n');
|
| 726 |
+
const a=document.createElement('a');
|
| 727 |
+
a.href=URL.createObjectURL(new Blob([csv],{{type:'text/csv'}}));
|
| 728 |
+
a.download='libbee-report-'+new Date().toISOString().substring(0,10)+'.csv';
|
| 729 |
+
a.click();
|
| 730 |
+
}}
|
| 731 |
+
|
| 732 |
async function loadMetrics(){{
|
| 733 |
try{{
|
| 734 |
const r=await fetch('/admin/metrics');
|
|
|
|
| 799 |
loadStats();
|
| 800 |
loadCFAnalytics();
|
| 801 |
loadRecentQueries();
|
| 802 |
+
document.getElementById('repRun').onclick=runReport;
|
| 803 |
+
document.getElementById('repPrint').onclick=printReport;
|
| 804 |
+
document.getElementById('repCsv').onclick=downloadCsv;
|
| 805 |
+
runReport();
|
| 806 |
loadMetrics();
|
| 807 |
loadRAGInfo();
|
| 808 |
loadStatus();
|
|
|
|
| 1175 |
if not data["recent_attempts"]:
|
| 1176 |
data["hint"] = ("No write attempts recorded since the last restart. "
|
| 1177 |
"Send a query through LibBee, then reload this page.")
|
| 1178 |
+
return data
|
| 1179 |
+
|
| 1180 |
+
@router.get("/canary-status")
|
| 1181 |
+
async def admin_canary_status(request: Request):
|
| 1182 |
+
"""Last 14 daily canary runs (the Worker's scheduled health probe).
|
| 1183 |
+
|
| 1184 |
+
Each run is one real question through the full pipeline, chosen from a
|
| 1185 |
+
fixed 14-question pool rotating by day-of-year. Canary traffic is excluded
|
| 1186 |
+
from analytics, so these runs never appear in the D1 usage data.
|
| 1187 |
+
"""
|
| 1188 |
+
_require_session(request)
|
| 1189 |
+
return await _worker_get("/canary/status")
|
| 1190 |
+
|