@@ -0,0 +1,803 @@
<!doctype html>
< html lang = "ru" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width,initial-scale=1" >
< title > Chat Agent for Patients — Регрессия< / title >
< style >
: root {
--bg : #f6f7fb ;
--panel : #ffffff ;
--border : #e5e7eb ;
--muted : #6b7280 ;
--accent : #4f6df5 ;
--ok : #16a34a ;
--err : #dc2626 ;
--warn : #d97706 ;
--mono : ui-monospace , SFMono-Regular , Menlo , Monaco , monospace ;
}
* { box-sizing : border-box ; }
html , body { margin : 0 ; background : var ( - - bg ) ; font-family : - apple-system , BlinkMacSystemFont , "Segoe UI" , Roboto , sans-serif ; font-size : 14 px ; color : #111827 ; }
header {
display : flex ;
align-items : center ;
gap : 18 px ;
padding : 12 px 20 px ;
background : var ( - - panel ) ;
border-bottom : 1 px solid var ( - - border ) ;
}
header h1 { margin : 0 ; font-size : 16 px ; font-weight : 600 ; }
. nav { display : flex ; gap : 14 px ; }
. nav-link { color : var ( - - muted ) ; text-decoration : none ; padding : 6 px 10 px ; border-radius : 6 px ; font-size : 13 px ; }
. nav-link : hover { background : #f3f4f6 ; color : #111827 ; }
. nav-link . active { background : var ( - - accent ) ; color : #fff ; }
main { padding : 20 px ; max-width : 1400 px ; margin : 0 auto ; }
h2 { margin : 0 0 10 px ; font-size : 18 px ; }
. sub { color : var ( - - muted ) ; font-size : 13 px ; margin-bottom : 16 px ; }
. panel { background : var ( - - panel ) ; border : 1 px solid var ( - - border ) ; border-radius : 8 px ; padding : 14 px 16 px ; margin-bottom : 16 px ; }
. panel h3 { margin : 0 0 10 px ; font-size : 14 px ; font-weight : 600 ; }
. row { display : flex ; gap : 10 px ; align-items : center ; flex-wrap : wrap ; }
label . field { display : inline-flex ; flex-direction : column ; gap : 4 px ; font-size : 12 px ; color : var ( - - muted ) ; }
input . num { width : 80 px ; padding : 5 px 8 px ; border : 1 px solid var ( - - border ) ; border-radius : 4 px ; font-family : var ( - - mono ) ; }
button . primary { padding : 7 px 14 px ; background : var ( - - accent ) ; color : #fff ; border : 0 ; border-radius : 4 px ; cursor : pointer ; font-size : 13 px ; }
button . primary : hover { background : #3f57c4 ; }
button . primary : disabled { background : #9ca3af ; cursor : not-allowed ; }
button . secondary { padding : 5 px 10 px ; background : #fff ; color : var ( - - accent ) ; border : 1 px solid var ( - - accent ) ; border-radius : 4 px ; cursor : pointer ; font-size : 12 px ; }
button . secondary : hover { background : var ( - - accent ) ; color : #fff ; }
table { width : 100 % ; border-collapse : collapse ; font-size : 13 px ; }
table th , table td { padding : 8 px 10 px ; text-align : left ; border-bottom : 1 px solid var ( - - border ) ; }
table th { font-weight : 600 ; color : var ( - - muted ) ; font-size : 12 px ; text-transform : uppercase ; letter-spacing : 0.04 em ; }
table tr . run-row { cursor : pointer ; }
table tr . run-row : hover { background : #f9fafb ; }
table tr . run-row . selected { background : #eff6ff ; }
. badge { display : inline-block ; padding : 2 px 8 px ; border-radius : 10 px ; font-size : 11 px ; font-weight : 600 ; }
. badge . running { background : #fef3c7 ; color : var ( - - warn ) ; }
. badge . done { background : #dcfce7 ; color : var ( - - ok ) ; }
. badge . error { background : #fee2e2 ; color : var ( - - err ) ; }
. stat { font-family : var ( - - mono ) ; }
. stat . pass { color : var ( - - ok ) ; }
. stat . fail { color : var ( - - err ) ; }
. progress {
height : 8 px ;
background : #f3f4f6 ;
border-radius : 4 px ;
overflow : hidden ;
margin-top : 8 px ;
}
. progress-bar {
height : 100 % ;
background : var ( - - accent ) ;
transition : width 0.3 s ;
}
. case-list { font-family : var ( - - mono ) ; font-size : 12 px ; max-height : 600 px ; overflow-y : auto ; border : 1 px solid var ( - - border ) ; border-radius : 6 px ; }
. case-row {
padding : 8 px 10 px ;
border-bottom : 1 px solid var ( - - border ) ;
display : grid ;
grid-template-columns : 50 px 1 fr 130 px 130 px 60 px ;
gap : 10 px ;
align-items : center ;
}
. case-row : last-child { border-bottom : 0 ; }
. case-row . fail { background : #fef2f2 ; }
. case-row . pass { background : #f0fdf4 ; }
. case-status {
font-size : 11 px ;
font-weight : 700 ;
text-align : center ;
padding : 2 px 0 ;
border-radius : 3 px ;
}
. case-status . pass { color : var ( - - ok ) ; background : #dcfce7 ; }
. case-status . fail { color : var ( - - err ) ; background : #fee2e2 ; }
. case-text { white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; }
. case-expected { color : #4b5563 ; }
. case-predicted . match { color : var ( - - ok ) ; }
. case-predicted . miss { color : var ( - - err ) ; }
. case-weight { color : var ( - - muted ) ; text-align : right ; font-size : 11 px ; }
. case-controls { display : flex ; gap : 12 px ; align-items : center ; flex-wrap : wrap ; margin-bottom : 10 px ; }
. case-controls input [ type = text ] { padding : 5 px 8 px ; border : 1 px solid var ( - - border ) ; border-radius : 4 px ; font-size : 12 px ; min-width : 240 px ; font-family : inherit ; }
. case-controls . filter-group { display : inline-flex ; gap : 4 px ; }
. case-controls . filter-btn {
padding : 4 px 10 px ; border : 1 px solid var ( - - border ) ; background : #fff ;
border-radius : 4 px ; cursor : pointer ; font-size : 12 px ; color : var ( - - muted ) ;
}
. case-controls . filter-btn . active { background : var ( - - accent ) ; color : #fff ; border-color : var ( - - accent ) ; }
. case-list-header {
display : grid ;
grid-template-columns : 50 px 1 fr 130 px 130 px 60 px ;
gap : 10 px ;
padding : 8 px 10 px ;
background : #f9fafb ;
font-weight : 600 ;
font-size : 11 px ;
text-transform : uppercase ;
letter-spacing : 0.04 em ;
color : var ( - - muted ) ;
border-bottom : 1 px solid var ( - - border ) ;
border-radius : 6 px 6 px 0 0 ;
position : sticky ;
top : 0 ;
}
. diff-block { margin-top : 12 px ; }
. diff-header { font-weight : 600 ; font-size : 13 px ; margin-bottom : 6 px ; }
. empty { color : var ( - - muted ) ; font-size : 13 px ; padding : 12 px 0 ; }
/* Блок выбора кейсов перед прогоном */
. picker-block {
border : 1 px solid var ( - - border ) ;
border-radius : 8 px ;
background : var ( - - panel ) ;
margin-bottom : 16 px ;
}
. picker-summary {
list-style : none ;
cursor : pointer ;
padding : 14 px 16 px ;
font-weight : 600 ;
font-size : 14 px ;
position : relative ;
padding-left : 36 px ;
}
. picker-summary :: -webkit-details-marker { display : none ; }
. picker-summary :: before {
content : "▶" ;
position : absolute ;
left : 16 px ;
transition : transform 0.15 s ;
font-size : 11 px ;
}
. picker-block [ open ] > . picker-summary :: before { transform : rotate ( 90 deg ) ; }
. picker-block [ open ] > . picker-summary { border-bottom : 1 px solid var ( - - border ) ; }
. picker-summary . sub { font-weight : 400 ; }
. picker-body { padding : 14 px 16 px ; }
. picker-tools { display : flex ; flex-wrap : wrap ; gap : 10 px ; align-items : center ; margin-bottom : 12 px ; }
. picker-tools select , . picker-tools input [ type = text ] {
padding : 5 px 8 px ; border : 1 px solid var ( - - border ) ; border-radius : 4 px ; font-size : 12 px ;
}
. picker-tools input [ type = text ] . range { min-width : 200 px ; font-family : var ( - - mono ) ; }
. picker-tools . picker-btn {
padding : 4 px 10 px ; border : 1 px solid var ( - - border ) ; background : #fff ;
border-radius : 4 px ; cursor : pointer ; font-size : 12 px ; color : #374151 ;
}
. picker-tools . picker-btn : hover { background : #f3f4f6 ; }
. picker-counter { color : var ( - - muted ) ; font-size : 12 px ; }
. picker-list-wrap {
max-height : 480 px ;
overflow-y : auto ;
border : 1 px solid var ( - - border ) ;
border-radius : 6 px ;
}
. picker-table {
width : 100 % ;
border-collapse : collapse ;
font-size : 12 px ;
}
. picker-table thead th {
position : sticky ; top : 0 ; background : #f9fafb ;
padding : 6 px 8 px ; text-align : left ;
font-weight : 600 ; font-size : 11 px ;
text-transform : uppercase ; letter-spacing : 0.04 em ;
color : var ( - - muted ) ;
border-bottom : 1 px solid var ( - - border ) ;
}
. picker-table tbody td { padding : 5 px 8 px ; border-bottom : 1 px solid var ( - - border ) ; font-family : var ( - - mono ) ; }
. picker-table tbody tr : hover { background : #f9fafb ; }
. picker-table tbody tr . cached-fail { background : #fef2f2 ; }
. picker-table tbody tr . cached-pass { background : #f0fdf4 ; }
. picker-table tbody tr . cached-fail : hover { background : #fee2e2 ; }
. picker-table tbody tr . cached-pass : hover { background : #dcfce7 ; }
. col-idx { width : 50 px ; text-align : right ; color : var ( - - muted ) ; }
. col-check { width : 36 px ; text-align : center ; }
. col-text { max-width : 480 px ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; }
. col-intent { width : 130 px ; }
. col-count { width : 70 px ; text-align : right ; color : var ( - - muted ) ; }
. col-cache { width : 90 px ; text-align : center ; font-weight : 700 ; }
. col-cache . pass { color : var ( - - ok ) ; }
. col-cache . fail { color : var ( - - err ) ; }
. col-cache . empty-c { color : var ( - - muted ) ; font-weight : 400 ; }
. toast { position : fixed ; bottom : 20 px ; right : 20 px ; background : #111827 ; color : #fff ; padding : 10 px 14 px ; border-radius : 6 px ; font-size : 13 px ; opacity : 0 ; transition : opacity 0.2 s ; z-index : 100 ; }
. toast . show { opacity : 1 ; }
. toast . err { background : var ( - - err ) ; }
< / style >
< / head >
< body >
< header >
< h1 > Chat Agent for Patients< / h1 >
< nav class = "nav" >
< a href = "/" class = "nav-link" > Отладка< / a >
< a href = "/sandbox.html" class = "nav-link" > Песочница< / a >
< a href = "/settings.html" class = "nav-link" > Настройки< / a >
< a href = "/regression.html" class = "nav-link active" > Регрессия< / a >
< a href = "/docs.html" class = "nav-link" > Документация< / a >
< / nav >
< / header >
< main >
< h2 > Регрессия роутера< / h2 >
< p class = "sub" > Прогон одношаговых кейсов классификатора (1573 фразы из реальных диалогов) на активной версии промпта < code > _router< / code > . Pass/fail сравниваются с ожидаемой веткой. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный.< / p >
< details class = "picker-block" id = "picker-block" >
< summary class = "picker-summary" >
Выбор кейсов < span class = "sub" id = "picker-summary-info" > — загружаю…< / span >
< / summary >
< div class = "picker-body" >
< div class = "picker-tools" >
< label class = "field" >
< span > Ветка (intent)< / span >
< select id = "picker-intent" >
< option value = "" > все ветки< / option >
< / select >
< / label >
< label class = "field" >
< span > Диапазон (по #)< / span >
< input type = "text" class = "range" id = "picker-range" placeholder = "например: 1-50, 200-300" >
< / label >
< button class = "picker-btn" onclick = "pickerApplyRange()" > Применить диапазон< / button >
< button class = "picker-btn" onclick = "pickerSelectAllVisible()" > Все (видимые)< / button >
< button class = "picker-btn" onclick = "pickerClearAll()" > Снять все< / button >
< button class = "picker-btn" onclick = "pickerSelectByCache('none')" > Только без кэша< / button >
< button class = "picker-btn" onclick = "pickerSelectByCache('fail')" > Только FAIL в кэше< / button >
< button class = "picker-btn" onclick = "pickerDropCached()" title = "Убрать галочки с тех, у которых уже есть результат в кэше" > Снять кэшированные< / button >
< span class = "picker-counter" id = "picker-counter" > выбрано 0< / span >
< / div >
< div class = "picker-list-wrap" >
< table class = "picker-table" >
< thead >
< tr >
< th class = "col-idx" > #< / th >
< th class = "col-check" > < input type = "checkbox" id = "picker-check-all" onchange = "pickerToggleAllVisible(this.checked)" > < / th >
< th class = "col-text" > запрос< / th >
< th class = "col-intent" > intent< / th >
< th class = "col-count" > частота< / th >
< th class = "col-cache" > кэш< / th >
< / tr >
< / thead >
< tbody id = "picker-tbody" >
< tr > < td colspan = "6" class = "empty" > — загружаю —< / td > < / tr >
< / tbody >
< / table >
< / div >
< div class = "row" style = "margin-top:14px;" >
< button class = "primary" id = "start-btn" onclick = "startRun()" disabled > Прогнать выбранное (0)< / button >
< span class = "sub" id = "start-hint" > Прогон идёт в фоне, можно свернуть и вернуться.< / span >
< / div >
< / div >
< / details >
< div class = "panel" >
< h3 > История прогонов< / h3 >
< table >
< thead >
< tr >
< th > #< / th >
< th > Стартовал< / th >
< th > Версия роутера< / th >
< th > Total< / th >
< th > Pass< / th >
< th > Fail< / th >
< th > Cache< / th >
< th > Статус< / th >
< / tr >
< / thead >
< tbody id = "runs-tbody" >
< tr > < td colspan = "8" class = "empty" > — загружаю —< / td > < / tr >
< / tbody >
< / table >
< / div >
< div class = "panel" id = "run-detail-panel" style = "display:none;" >
< h3 id = "run-detail-title" > Детали прогона< / h3 >
< div id = "run-detail-body" > < / div >
< / div >
< / main >
< div class = "toast" id = "toast" > < / div >
< script >
const $ = ( id ) => document . getElementById ( id ) ;
const esc = ( s ) => String ( s ? ? "" ) . replace ( /[&<>"']/g , c => ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } [ c ] ) ) ;
let selectedRunId = null ;
let pollHandle = null ;
let caseFilter = "all" ; // "all" | "pass" | "fail"
let caseSearch = "" ;
let currentCases = [ ] ; // последние полученные кейсы выбранного прогона
function toast ( msg , kind = "ok" ) {
const t = $ ( "toast" ) ;
t . textContent = msg ;
t . className = "toast show" + ( kind === "err" ? " err" : "" ) ;
setTimeout ( ( ) => t . className = "toast" , 2500 ) ;
}
async function api ( path , opts = { } ) {
const res = await fetch ( path , opts ) ;
if ( ! res . ok ) {
let msg = ` ${ res . status } ` ;
try { const d = await res . json ( ) ; msg = d . detail || JSON . stringify ( d ) ; } catch ( _ ) { }
throw new Error ( msg ) ;
}
if ( res . status === 204 ) return null ;
return res . json ( ) ;
}
function fmtDate ( iso ) {
if ( ! iso ) return "—" ;
try {
const d = new Date ( iso ) ;
return d . toLocaleString ( "ru-RU" , { day : "2-digit" , month : "2-digit" , year : "2-digit" , hour : "2-digit" , minute : "2-digit" , second : "2-digit" } ) ;
} catch ( _ ) { return iso ; }
}
async function refreshRuns ( ) {
try {
const d = await api ( "/eval/runs" ) ;
renderRunsTable ( d . runs || [ ] ) ;
} catch ( e ) {
$ ( "runs-tbody" ) . innerHTML = ` <tr><td colspan="9" class="empty">Ошибка: ${ esc ( e . message ) } </td></tr> ` ;
}
}
function renderRunsTable ( runs ) {
const body = $ ( "runs-tbody" ) ;
if ( ! runs . length ) {
body . innerHTML = '<tr><td colspan="8" class="empty">Прогонов ещё не было — выберите кейсы в блоке выше и нажмите «Прогнать выбранное».</td></tr>' ;
return ;
}
body . innerHTML = runs . map ( r => {
const cls = r . id === selectedRunId ? "selected" : "" ;
const versionStr = r . router _config _version ? ` v ${ r . router _config _version } ` : "—" ;
return `
<tr class="run-row ${ cls } " onclick="selectRun( ${ r . id } )">
<td># ${ r . id } </td>
<td> ${ fmtDate ( r . started _at ) } </td>
<td><code> ${ versionStr } </code></td>
<td class="stat"> ${ r . total } </td>
<td class="stat pass"> ${ r . passed } </td>
<td class="stat fail"> ${ r . failed } </td>
<td class="stat" title="кэш-хитов из ${ r . total } "> ${ r . cache _hits } </td>
<td> ${ renderStatusBadge ( r ) } </td>
</tr>
` ;
} ) . join ( "" ) ;
}
function renderStatusBadge ( r ) {
if ( r . status === "running" ) {
const done = r . passed + r . failed ;
const pct = r . total > 0 ? Math . round ( 100 * done / r . total ) : 0 ;
return ` <span class="badge running"> ${ pct } %</span> ` ;
}
if ( r . status === "done" ) return '<span class="badge done">готово</span>' ;
if ( r . status === "error" ) return '<span class="badge error">ошибка</span>' ;
return ` <span class="badge"> ${ esc ( r . status ) } </span> ` ;
}
// ---------- Picker (выбор кейсов) ----------
let pickerCases = [ ] ; // полный список из /router-cases-with-status
let pickerSelected = new Set ( ) ; // text_hash выбранных
let pickerIntents = [ ] ; // уникальные intents для select
let pickerVersionLabel = "" ;
async function loadPicker ( ) {
try {
const d = await api ( "/eval/router-cases-with-status" ) ;
pickerCases = d . cases || [ ] ;
pickerVersionLabel = d . router _config _version ? ` v ${ d . router _config _version } ` : "—" ;
pickerIntents = Array . from ( new Set ( pickerCases . map ( c => c . expected _intent ) ) ) . sort ( ) ;
fillPickerIntentSelect ( ) ;
renderPickerInfo ( d ) ;
renderPickerTable ( ) ;
} catch ( e ) {
$ ( "picker-tbody" ) . innerHTML = ` <tr><td colspan="6" class="empty">Ошибка: ${ esc ( e . message ) } </td></tr> ` ;
}
}
function fillPickerIntentSelect ( ) {
const sel = $ ( "picker-intent" ) ;
sel . innerHTML =
'<option value="">все ветки</option>' +
pickerIntents . map ( i => ` <option value=" ${ esc ( i ) } "> ${ esc ( i ) } </option> ` ) . join ( "" ) ;
sel . onchange = ( ) => renderPickerTable ( ) ;
}
function renderPickerInfo ( d ) {
const cached = pickerCases . filter ( c => c . cached _predicted !== null ) . length ;
$ ( "picker-summary-info" ) . textContent =
` — активная версия роутера ${ pickerVersionLabel } · ${ d . total } кейсов всего · в кэше ${ cached } ` ;
}
function pickerVisibleCases ( ) {
const intent = $ ( "picker-intent" ) . value ;
if ( ! intent ) return pickerCases ;
return pickerCases . filter ( c => c . expected _intent === intent ) ;
}
function renderPickerTable ( ) {
const visible = pickerVisibleCases ( ) ;
const tbody = $ ( "picker-tbody" ) ;
if ( ! visible . length ) {
tbody . innerHTML = '<tr><td colspan="6" class="empty">— нет кейсов под фильтр —</td></tr>' ;
refreshPickerCounter ( ) ;
return ;
}
tbody . innerHTML = visible . map ( c => {
const checked = pickerSelected . has ( c . text _hash ) ? "checked" : "" ;
const cacheCell = renderCacheCell ( c ) ;
const rowCls = c . cached _predicted === null ? "" : ( c . cached _is _pass ? "cached-pass" : "cached-fail" ) ;
return `
<tr class=" ${ rowCls } ">
<td class="col-idx"> ${ c . idx } </td>
<td class="col-check"><input type="checkbox" data-hash=" ${ c . text _hash } " ${ checked } onchange="pickerToggleOne(this)"></td>
<td class="col-text" title=" ${ esc ( c . text ) } "> ${ esc ( c . text ) } </td>
<td class="col-intent"> ${ esc ( c . expected _intent ) } </td>
<td class="col-count">× ${ c . count } </td>
<td class="col-cache ${ cacheCellClass ( c ) } "> ${ cacheCell } </td>
</tr>
` ;
} ) . join ( "" ) ;
refreshPickerCounter ( ) ;
syncPickerHeaderCheckbox ( ) ;
}
function renderCacheCell ( c ) {
if ( c . cached _predicted === null ) return "—" ;
if ( c . cached _is _pass ) return "PASS" ;
return ` FAIL<div class="sub" style="font-size:10px;">→ ${ esc ( c . cached _predicted ) } </div> ` ;
}
function cacheCellClass ( c ) {
if ( c . cached _predicted === null ) return "empty-c" ;
return c . cached _is _pass ? "pass" : "fail" ;
}
function pickerToggleOne ( cb ) {
const h = cb . dataset . hash ;
if ( cb . checked ) pickerSelected . add ( h ) ; else pickerSelected . delete ( h ) ;
refreshPickerCounter ( ) ;
syncPickerHeaderCheckbox ( ) ;
}
function pickerToggleAllVisible ( checked ) {
const visible = pickerVisibleCases ( ) ;
for ( const c of visible ) {
if ( checked ) pickerSelected . add ( c . text _hash ) ;
else pickerSelected . delete ( c . text _hash ) ;
}
renderPickerTable ( ) ;
}
function pickerSelectAllVisible ( ) { pickerToggleAllVisible ( true ) ; }
function pickerClearAll ( ) {
pickerSelected . clear ( ) ;
renderPickerTable ( ) ;
}
function pickerSelectByCache ( mode ) {
const visible = pickerVisibleCases ( ) ;
pickerSelected . clear ( ) ;
for ( const c of visible ) {
if ( mode === "none" && c . cached _predicted === null ) pickerSelected . add ( c . text _hash ) ;
else if ( mode === "fail" && c . cached _predicted !== null && ! c . cached _is _pass ) pickerSelected . add ( c . text _hash ) ;
}
renderPickerTable ( ) ;
}
function pickerApplyRange ( ) {
const raw = $ ( "picker-range" ) . value . trim ( ) ;
if ( ! raw ) { toast ( "Введите диапазон, например: 1-50, 200-300" , "err" ) ; return ; }
const ranges = parseRanges ( raw ) ;
if ( ! ranges . length ) { toast ( "Не удалось разобрать диапазон" , "err" ) ; return ; }
const visible = pickerVisibleCases ( ) ;
pickerSelected . clear ( ) ;
for ( const c of visible ) {
for ( const [ lo , hi ] of ranges ) {
if ( c . idx >= lo && c . idx <= hi ) { pickerSelected . add ( c . text _hash ) ; break ; }
}
}
renderPickerTable ( ) ;
}
function parseRanges ( s ) {
// "1-50, 200-300, 5" → [[1,50],[200,300],[5,5]]
const out = [ ] ;
for ( const part of s . split ( "," ) ) {
const p = part . trim ( ) ;
if ( ! p ) continue ;
const m = p . match ( /^(\d+)\s*-\s*(\d+)$/ ) ;
if ( m ) {
const a = parseInt ( m [ 1 ] , 10 ) , b = parseInt ( m [ 2 ] , 10 ) ;
out . push ( [ Math . min ( a , b ) , Math . max ( a , b ) ] ) ;
} else if ( /^\d+$/ . test ( p ) ) {
const n = parseInt ( p , 10 ) ;
out . push ( [ n , n ] ) ;
} else {
return [ ] ;
}
}
return out ;
}
function pickerSelectionStats ( ) {
// По cached_predicted делим выбранные на «новые» (LLM нужен) и «в кэше» (мгновенно).
let cached = 0 ;
for ( const c of pickerCases ) {
if ( ! pickerSelected . has ( c . text _hash ) ) continue ;
if ( c . cached _predicted !== null ) cached ++ ;
}
return { total : pickerSelected . size , cached , fresh : pickerSelected . size - cached } ;
}
function refreshPickerCounter ( ) {
const s = pickerSelectionStats ( ) ;
$ ( "picker-counter" ) . textContent =
s . total === 0
? "выбрано 0"
: ` выбрано ${ s . total } (новых: ${ s . fresh } , в кэше: ${ s . cached } ) ` ;
const btn = $ ( "start-btn" ) ;
btn . disabled = s . total === 0 ;
if ( s . total === 0 ) {
btn . textContent = "Прогнать выбранное (0)" ;
} else if ( s . cached === 0 ) {
btn . textContent = ` Прогнать выбранное ( ${ s . fresh } ) ` ;
} else if ( s . fresh === 0 ) {
btn . textContent = ` Прогнать выбранное ( ${ s . total } из кэша) ` ;
} else {
btn . textContent = ` Прогнать выбранное ( ${ s . fresh } новых + ${ s . cached } из кэша) ` ;
}
const hint = $ ( "start-hint" ) ;
if ( hint ) {
if ( s . fresh > 0 ) {
hint . textContent = ` Через LLM пойдут только ${ s . fresh } новых, остальные ${ s . cached } возьмутся из кэша мгновенно. ` ;
} else if ( s . cached > 0 ) {
hint . textContent = "Все выбранные уже в кэше на этой версии — прогон будет мгновенным." ;
} else {
hint . textContent = "Прогон идёт в фоне, можно свернуть и вернуться." ;
}
}
}
function pickerDropCached ( ) {
for ( const c of pickerCases ) {
if ( c . cached _predicted !== null ) pickerSelected . delete ( c . text _hash ) ;
}
renderPickerTable ( ) ;
}
function syncPickerHeaderCheckbox ( ) {
const visible = pickerVisibleCases ( ) ;
const checked = visible . length > 0 && visible . every ( c => pickerSelected . has ( c . text _hash ) ) ;
$ ( "picker-check-all" ) . checked = checked ;
}
async function startRun ( ) {
const hashes = Array . from ( pickerSelected ) ;
if ( ! hashes . length ) { toast ( "Выберите хотя бы один кейс" , "err" ) ; return ; }
$ ( "start-btn" ) . disabled = true ;
try {
const r = await api ( "/eval/runs" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( { suite : "router" , text _hashes : hashes } ) ,
} ) ;
toast ( ` Прогон # ${ r . id } запущен ( ${ r . total } кейсов) ` ) ;
selectedRunId = r . id ;
// Свернуть пикер, чтобы показать прогресс прогона.
$ ( "picker-block" ) . open = false ;
await refreshRuns ( ) ;
await selectRun ( r . id ) ;
startPolling ( ) ;
} catch ( e ) {
toast ( "Ошибка: " + e . message , "err" ) ;
} finally {
refreshPickerCounter ( ) ;
}
}
async function selectRun ( runId ) {
selectedRunId = runId ;
await refreshRuns ( ) ;
try {
const d = await api ( ` /eval/runs/ ${ runId } ` ) ;
renderRunDetail ( d ) ;
} catch ( e ) {
toast ( "Ошибка: " + e . message , "err" ) ;
}
}
function renderRunDetail ( d ) {
const panel = $ ( "run-detail-panel" ) ;
const title = $ ( "run-detail-title" ) ;
const body = $ ( "run-detail-body" ) ;
panel . style . display = "block" ;
const r = d . run ;
currentCases = d . cases || [ ] ;
const versionStr = r . router _config _version ? ` v ${ r . router _config _version } ` : "—" ;
title . textContent = ` Прогон # ${ r . id } · роутер ${ versionStr } · ${ r . status } ` ;
let progressHtml = "" ;
if ( r . status === "running" && r . total > 0 ) {
const done = r . passed + r . failed ;
const pct = Math . round ( 100 * done / r . total ) ;
progressHtml = `
<div class="progress"><div class="progress-bar" style="width: ${ pct } %"></div></div>
<div class="sub">Обработано ${ done } / ${ r . total } , кэш ${ r . cache _hits } </div>
` ;
}
let errorHtml = "" ;
if ( r . status === "error" && r . error _text ) {
errorHtml = ` <div class="empty" style="color:var(--err)">Ошибка: ${ esc ( r . error _text ) } </div> ` ;
}
// Контролы фильтра/поиска кейсов.
const total = currentCases . length ;
const passes = currentCases . filter ( c => c . is _pass ) . length ;
const fails = total - passes ;
const controlsHtml = `
<div class="case-controls">
<span class="diff-header" style="margin:0;">Кейсы прогона</span>
<span class="filter-group">
<button class="filter-btn ${ caseFilter === 'all' ? 'active' : '' } " onclick="setCaseFilter('all')">все ( ${ total } )</button>
<button class="filter-btn ${ caseFilter === 'pass' ? 'active' : '' } " onclick="setCaseFilter('pass')">pass ( ${ passes } )</button>
<button class="filter-btn ${ caseFilter === 'fail' ? 'active' : '' } " onclick="setCaseFilter('fail')">fail ( ${ fails } )</button>
</span>
<input type="text" id="case-search" placeholder="🔍 поиск по тексту…" value=" ${ esc ( caseSearch ) } " oninput="onCaseSearch(this.value)">
</div>
<div id="case-list-root"></div>
` ;
let diffHtml = "" ;
if ( d . diff && d . diff . prev _run _id ) {
const newFails = renderCasesSection ( d . diff . new _fails , ` 🔴 Новые fail vs прогон # ${ d . diff . prev _run _id } ` , "—" ) ;
const newPasses = renderCasesSection ( d . diff . new _passes , ` 🟢 Новые pass vs прогон # ${ d . diff . prev _run _id } ` , "—" ) ;
diffHtml = ` <div class="diff-block"> ${ newFails } ${ newPasses } </div> ` ;
} else if ( d . diff && r . status === "done" ) {
diffHtml = ` <div class="diff-block sub">Это первый завершённый прогон на текущей версии роутера — сравнивать не с чем.</div> ` ;
}
body . innerHTML = `
${ progressHtml }
${ errorHtml }
${ controlsHtml }
${ diffHtml }
` ;
renderCaseList ( ) ;
}
function setCaseFilter ( f ) {
caseFilter = f ;
// Перерисовываем все контролы (счётчики не меняются, но кнопки — да) и список.
const detailBody = $ ( "run-detail-body" ) ;
if ( ! detailBody ) return ;
// Простой путь: перерисовать кнопки руками, не трогая остальное.
detailBody . querySelectorAll ( ".filter-btn" ) . forEach ( b => b . classList . remove ( "active" ) ) ;
const map = { all : 0 , pass : 1 , fail : 2 } ;
const idx = map [ f ] ;
const btns = detailBody . querySelectorAll ( ".filter-btn" ) ;
if ( btns [ idx ] ) btns [ idx ] . classList . add ( "active" ) ;
renderCaseList ( ) ;
}
function onCaseSearch ( value ) {
caseSearch = value ;
renderCaseList ( ) ;
}
function renderCaseList ( ) {
const root = $ ( "case-list-root" ) ;
if ( ! root ) return ;
const q = caseSearch . trim ( ) . toLowerCase ( ) ;
let cases = currentCases ;
if ( caseFilter === "pass" ) cases = cases . filter ( c => c . is _pass ) ;
else if ( caseFilter === "fail" ) cases = cases . filter ( c => ! c . is _pass ) ;
if ( q ) cases = cases . filter ( c => c . text . toLowerCase ( ) . includes ( q ) ) ;
if ( ! cases . length ) {
root . innerHTML = '<div class="empty">— ничего не найдено —</div>' ;
return ;
}
const header = `
<div class="case-list-header">
<div>результат</div>
<div>запрос (реплика пациента)</div>
<div>ответ роутера</div>
<div>правильный</div>
<div style="text-align:right;">вес</div>
</div>
` ;
const rows = cases . map ( c => {
const cls = c . is _pass ? "pass" : "fail" ;
const predCls = c . is _pass ? "match" : "miss" ;
const status = c . is _pass ? "PASS" : "FAIL" ;
return `
<div class="case-row ${ cls } ">
<div class="case-status ${ cls } "> ${ status } </div>
<div class="case-text" title=" ${ esc ( c . text ) } "> ${ esc ( c . text ) } </div>
<div class="case-predicted ${ predCls } "> ${ esc ( c . predicted _intent ) } </div>
<div class="case-expected"> ${ esc ( c . expected _intent ) } </div>
<div class="case-weight">× ${ c . count _weight } </div>
</div>
` ;
} ) . join ( "" ) ;
root . innerHTML = ` <div class="case-list"> ${ header } ${ rows } </div> ` ;
}
function renderCasesSection ( cases , title , emptyMsg ) {
if ( ! cases || ! cases . length ) {
return ` <div class="diff-header"> ${ esc ( title ) } </div><div class="empty"> ${ esc ( emptyMsg ) } </div> ` ;
}
const rows = cases . map ( c => `
<div class="case-row fail">
<div class="case-status fail">FAIL</div>
<div class="case-text" title=" ${ esc ( c . text ) } "> ${ esc ( c . text ) } </div>
<div class="case-predicted miss"> ${ esc ( c . predicted _intent ) } </div>
<div class="case-expected"> ${ esc ( c . expected _intent ) } </div>
<div class="case-weight">× ${ c . count _weight } </div>
</div>
` ) . join ( "" ) ;
return `
<div class="diff-header" style="margin-top:14px;"> ${ esc ( title ) } <span class="sub">( ${ cases . length } )</span></div>
<div class="case-list"> ${ rows } </div>
` ;
}
function startPolling ( ) {
stopPolling ( ) ;
pollHandle = setInterval ( async ( ) => {
try {
const d = await api ( "/eval/runs" ) ;
const runs = d . runs || [ ] ;
renderRunsTable ( runs ) ;
if ( selectedRunId ) {
const cur = runs . find ( r => r . id === selectedRunId ) ;
if ( cur ) {
const detail = await api ( ` /eval/runs/ ${ selectedRunId } ` ) ;
renderRunDetail ( detail ) ;
if ( cur . status !== "running" ) {
stopPolling ( ) ;
if ( cur . status === "done" ) {
toast ( ` Прогон # ${ cur . id } завершён: ${ cur . passed } / ${ cur . total } ` ) ;
} else if ( cur . status === "error" ) {
toast ( ` Прогон # ${ cur . id } упал с ошибкой ` , "err" ) ;
}
}
}
}
} catch ( e ) {
console . warn ( "poll failed" , e ) ;
}
} , 2000 ) ;
}
function stopPolling ( ) {
if ( pollHandle ) {
clearInterval ( pollHandle ) ;
pollHandle = null ;
}
}
( async ( ) => {
await loadPicker ( ) ;
await refreshRuns ( ) ;
// Если есть «running» прогон — сразу подсветить и начать polling.
try {
const d = await api ( "/eval/runs" ) ;
const running = ( d . runs || [ ] ) . find ( r => r . status === "running" ) ;
if ( running ) {
selectedRunId = running . id ;
await selectRun ( running . id ) ;
startPolling ( ) ;
}
} catch ( _ ) { }
} ) ( ) ;
< / script >
< / body >
< / html >