diff --git a/core/src/execute/aggregate.ts b/core/src/execute/aggregate.ts index 2aee517..f3c23b8 100644 --- a/core/src/execute/aggregate.ts +++ b/core/src/execute/aggregate.ts @@ -141,12 +141,18 @@ export function buildUnifiedReport( }; } -/** Format a "provider/model" label, falling back to the attacker model for the judge. */ +/** + * Format a "provider/model" label, falling back to the attacker model for the judge. + * `openai-compatible` is a generic custom-baseURL wrapper, not a real vendor identity, + * so it's dropped and only the model name is shown for that provider. + */ export function modelLabel( attackerLlm: { provider: string; model: string }, judgeLlm?: { provider: string; model: string } ): { attackModel: string; judgeModel: string } { - const attackModel = `${attackerLlm.provider}/${attackerLlm.model}`; - const judgeModel = judgeLlm ? `${judgeLlm.provider}/${judgeLlm.model}` : attackModel; + const format = (llm: { provider: string; model: string }): string => + llm.provider === "openai-compatible" ? llm.model : `${llm.provider}/${llm.model}`; + const attackModel = format(attackerLlm); + const judgeModel = judgeLlm ? format(judgeLlm) : attackModel; return { attackModel, judgeModel }; } diff --git a/core/src/report/render.ts b/core/src/report/render.ts index 6c5a5be..005e9f8 100644 --- a/core/src/report/render.ts +++ b/core/src/report/render.ts @@ -3,6 +3,7 @@ * Shared by the CLI, MCP server, SDK, and browser extension runners. */ import type { ReportViewModel, ResultViewModel, TurnViewModel, DetailCard } from "./types.js"; +import { formatStandardsLabel } from "../evaluators/standards.js"; /** Format a token count for display (e.g. 51300 → "51.3K"). */ function formatTokenCount(n: number): string { @@ -197,7 +198,7 @@ export function renderReport(model: ReportViewModel): string { : null; const showTestHeading = e.results.length > 1; const cards = e.results - .map((r, i) => resultDetailCard(r, i, model.mode, showTestHeading, idx)) + .map((r, i) => resultDetailCard(r, i, model.mode, showTestHeading, idx, e.standards)) .join(""); return `
@@ -342,7 +343,7 @@ export function renderReport(model: ReportViewModel): string { .exec-verdict-text.pass{color:var(--pass)} .exec-verdict-text.fail{color:var(--fail)} .exec-verdict-text.error{color:#D97706} - .exec-risk{font-size:11px;font-weight:600;padding:4px 11px;border-radius:999px;border:1px solid;white-space:nowrap} + .exec-risk{font-size:11px;font-weight:600;padding:4px 11px;border-radius:999px;border:1px solid;white-space:nowrap;cursor:default} .gauge-value{font-size:22px;font-weight:800;color:var(--text);text-align:center;margin-top:-30px} .gauge-sub{font-size:12px;color:var(--muted);text-align:center;margin-top:2px} .sc-value{font-size:26px;font-weight:800;line-height:1;color:var(--text)} @@ -393,6 +394,8 @@ export function renderReport(model: ReportViewModel): string { .eval-meta-row{display:flex;flex-wrap:wrap;gap:36px;margin-bottom:14px} .eval-meta-col{display:flex;flex-direction:column;gap:6px} .eval-meta-col .meta-v-lg{font-size:20px;font-weight:700;color:var(--text);line-height:1} + .eval-meta-col.standards-col{margin-left:20px} + .eval-meta-col .meta-v-standards{font-size:13px;color:var(--text-2);line-height:1.5} /* ── Transcript ── */ .transcript-toggle{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;color:var(--muted);background:none;border:none;cursor:pointer;padding:12px 0 0;margin-top:2px;border-top:1px solid var(--line);width:100%} @@ -403,17 +406,30 @@ export function renderReport(model: ReportViewModel): string { .transcript{border:1px solid var(--line);border-radius:8px;overflow:hidden} .transcript-header{padding:8px 12px;background:var(--surface-2);font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px} .tc-count{font-weight:400;color:var(--muted-2)} - .turn{padding:12px 0} + .transcript-body{display:flex;align-items:flex-start;gap:16px;padding:12px 12px 12px 0;max-height:560px;overflow-y:auto;overscroll-behavior:contain} + .turn-rail{display:flex;flex-direction:column;align-items:center;gap:16px;flex-shrink:0;position:sticky;top:4px;padding:4px 0 4px 12px} + .turn-step{position:relative;width:26px;height:26px;border-radius:7px;border:1px solid var(--line-2);background:var(--surface);color:var(--muted);font:700 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0} + .turn-step:hover{border-color:var(--muted-2);color:var(--text)} + .turn-step.active{border-color:var(--accent);color:var(--accent);background:rgba(255,77,79,0.08);box-shadow:0 0 0 2px rgba(255,77,79,0.18)} + .turn-step:not(:first-child)::before{content:"";position:absolute;bottom:100%;left:50%;transform:translateX(-50%);width:1px;height:16px;background:var(--line-2)} + .turn-content{flex:1;min-width:0} + .turn{padding:0 0 20px} + .turn:last-child{padding-bottom:0} + .turn-heading{display:flex;align-items:center;gap:10px;margin-bottom:10px} + .turn-heading-label{font-size:11px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);white-space:nowrap} + .turn-heading-line{flex:1;height:1px;background:var(--line)} .turn-row{padding:2px 16px} .turn-row.attacker-row{padding-left:64px} .turn-row.agent-row{margin-top:12px} .turn-role{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:700;color:var(--text);margin-bottom:6px} .turn-role .turn-icon{display:inline-flex;align-items:center} - .turn-role .turn-sep{color:var(--muted-2);font-weight:400} - .turn-role .turn-num{color:var(--muted);font-weight:500} .turn-row pre{margin:0;white-space:pre-wrap;word-break:break-word;font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--text-2)} .agent-bubble{display:inline-block;max-width:78%;background:var(--surface-2);border-radius:10px;padding:10px 14px} .agent-bubble.turn-highlight{background:var(--fail-bg);border:1px dashed var(--fail-border)} + @media(max-width:640px){ + .turn-rail{display:none} + .transcript-body{padding-left:12px} + } /* ── Footer ── */ .report-footer{max-width:960px;margin:40px auto 0;padding:16px 24px;border-top:1px solid var(--line);display:flex;justify-content:space-between;align-items:center} @@ -425,6 +441,8 @@ export function renderReport(model: ReportViewModel): string { .cover{-webkit-print-color-adjust:exact;print-color-adjust:exact} .eval-detail{border:1px solid var(--line)} .scope-card,.eval-detail,.exec-strip{break-inside:avoid;box-shadow:none} + .transcript-body{max-height:none;overflow:visible} + .turn-rail{position:static} } @media(max-width:640px){ .cover-meta{grid-template-columns:1fr} @@ -470,7 +488,7 @@ export function renderReport(model: ReportViewModel): string {
Overall Verdict
${overallVerdict === "PASS" ? "Pass" : overallVerdict === "ERROR" ? "Error" : "Fail"}
-
${overallVerdict === "ERROR" ? "Inconclusive" : riskLevel.label.toUpperCase()}
+
${overallVerdict === "ERROR" ? "Inconclusive" : riskLevel.label.toUpperCase()}
@@ -554,7 +572,39 @@ export function renderReport(model: ReportViewModel): string { var open = wrap.classList.toggle('open'); btn.querySelector('.tt-label').textContent = open ? 'View less details' : 'View more details'; btn.querySelector('svg').style.transform = open ? 'rotate(180deg)' : 'rotate(0deg)'; + if (open) { + requestAnimationFrame(function(){ + var flagged = wrap.querySelector('.turn-highlight'); + if (flagged) flagged.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + } + }); + }); + // Turn rail: click a step to jump to that turn; highlight whichever turn is + // topmost in view as the user scrolls (scroll-spy). + document.querySelectorAll('.transcript-body').forEach(function(body){ + var steps = body.querySelectorAll('.turn-step'); + if (!steps.length) return; + var turns = body.querySelectorAll('.turn[id]'); + var inView = {}; + var setActive = function(id){ + steps.forEach(function(s){ s.classList.toggle('active', s.dataset.turn === id); }); + }; + steps.forEach(function(step){ + step.addEventListener('click', function(){ + var target = document.getElementById(step.dataset.turn); + if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); }); + var observer = new IntersectionObserver(function(entries){ + entries.forEach(function(entry){ + inView[entry.target.id] = entry.isIntersecting; + }); + var topmost = null; + turns.forEach(function(t){ if (!topmost && inView[t.id]) topmost = t.id; }); + if (topmost) setActive(topmost); + }, { root: body, threshold: 0.4 }); + turns.forEach(function(t){ observer.observe(t); }); }); })(); @@ -564,35 +614,41 @@ export function renderReport(model: ReportViewModel): string { // ── Result detail card ──────────────────────────────────────────── -/** Role label row: icon + name, optionally " | Turn N". */ -function roleLabel(icon: string, name: string, turnLabel?: string): string { - const suffix = turnLabel - ? `|${turnLabel}` - : ""; - return `
${icon}${name}${suffix}
`; +/** Role label row: icon + name. */ +function roleLabel(icon: string, name: string): string { + return `
${icon}${name}
`; +} + +/** "TURN N ────" heading rule that opens each turn block. */ +function turnHeading(turnIndex: number): string { + return `
Turn ${turnIndex}
`; } /** Render a single conversation turn: the attacker prompt as plain text, the agent * response as a left-anchored bubble (highlighted when the judge cited this turn * in its `failingTurns`). There's no per-turn judge — the transcript is judged once, * at the end, over the whole exchange — so the flagged-turn signal comes from the - * result-level `judge.failingTurns` list, not from `turn.judge`. */ -function renderTurn(turn: TurnViewModel, failingTurns: Set): string { + * result-level `judge.failingTurns` list, not from `turn.judge`. + * + * `id` anchors this block for the turn-rail's scroll-spy + click-to-scroll (see the + * inline