nikeshn commited on
Commit
b674d50
·
verified ·
1 Parent(s): f7bc010

Update src/api/admin.py

Browse files
Files changed (1) hide show
  1. 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
+