// packages/ui-gen/src/evaluation/llm-evaluator.ts // // Parallel per-criterion LLM evaluator with cached mother prompt. // // Architecture: // 1. Mother prompt (cached, sent once): user request + contract + design system + all criteria definitions // 2. 7 parallel per-criterion LLM calls, each reusing the cached mother prompt // 3. Each call returns a focused tool response for one criterion // // Tiers: // Tier 1 — Critical (always blocks): functionality, crash → { pass: boolean, issues?: string[] } // Tier 2 — Quality (LLM decides): interactivity, accessibility, layout, loading, visual → { result: 'fail'|'warn'|'pass', issues?: string[] } // // Caching: Anthropic ephemeral cache, OpenAI auto-cache, Google explicit cache. // Same system prompt across all 7 calls → first call caches, 6 remaining hit cache. import type { EvalIssue, EvalResult, EvalCategory } from './types-public.js'; import { createAgent } from '../harness/llm-router'; import type { AgentConfig, LLMToolDef, LLMAgent } from '../harness/llm-router'; import type { JsonObject } from '@ggui-ai/protocol'; import { getCriterionById } from './types-public.js'; // ============================================================================= // Helpers // ============================================================================= /** * Safely coerce LLM tool output to string[]. * * LLMs sometimes return `issues` as a JSON string instead of an array. * Iterating a string goes character-by-character, creating thousands of * single-char "issues". This helper handles: string[] (pass-through), * string (JSON parse), or anything else (empty array). */ function coerceStringArray(value: unknown): string[] { if (Array.isArray(value)) { return value.filter((v): v is string => typeof v === 'string' && v.length > 1); } if (typeof value === 'string') { try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { return parsed.filter((v): v is string => typeof v === 'string' && v.length > 1); } } catch { // Not JSON — treat the whole string as a single issue if (value.length > 1) return [value]; } } return []; } // ============================================================================= // Public interfaces // ============================================================================= export interface LLMEvalContext { sourceCode: string; originalPrompt: string; designContext?: string; contract?: unknown; } export interface LLMEvalConfig { provider: 'claude' | 'openai' | 'google' | 'openrouter'; model?: string; /** * Explicit per-call credentials/routing (#484) — see * `AgentConfig.routeOverride`. Threaded onto the agent this * evaluator constructs so it never falls back to reading * `process.env`. Absent (default) preserves existing behavior. */ routeOverride?: AgentConfig['routeOverride']; /** * Provider-429-retry observer (#489) — see `AgentConfig.onRetry`. * Threaded onto the agent this evaluator constructs so a rate-limited * retry inside an evaluation turn's `apiCall()` reaches the same * caller the coding leg reaches. Absent (default) preserves existing * behavior (retries still happen; no observer fires). */ onRetry?: AgentConfig['onRetry']; } /** A request-specific evaluation criterion generated by the LLM. */ export interface DynamicCriterion { name: string; description: string; } /** Pre-warmed evaluation context (generated during coding, consumed at eval time). */ export interface PreWarmedEvalContext { motherPrompt: string; dynamicCriteria: DynamicCriterion[]; } // ============================================================================= // Mother prompt builder (cacheable context) // ============================================================================= export interface EvalContext { originalPrompt: string; contract?: unknown; designSystemSummary?: string; } export function buildMotherPrompt(ctx: EvalContext): string { const parts: string[] = []; parts.push(`You are evaluating a generated React component built with the ggui design system. The code has already passed automated checks (compilation, security, imports, design tokens). Your job is to evaluate ONE specific quality criterion per call.`); parts.push(`## About This Prompt This system prompt has 2 sections: 1. **Context** — the original user request and data contract 2. **Criteria Reference** — definitions for all 7 evaluation criteria (your call will focus on one) ## Input Messages You will receive ONE user message per call containing: 1. Which criterion to evaluate (e.g., "Evaluate FUNCTIONALITY") 2. The source code with line numbers 3. Example outputs showing the expected tool response format Respond with a single tool call (evaluate_{criterion}). Report ALL issues you find for that criterion, not just the first one. Each issue should be an actionable fix instruction. ## Issue-array discipline (applies to EVERY criterion) The "issues" array is for confirmed defects ONLY. Before adding an entry, it must be something you are CERTAIN is missing or broken. NEVER add: - a self-negating note ("…actually this is fine", "so no issue here", "no actual crash scenarios are present"); - a speculative / conditional note ("if the data is shaped X then…", "this may break when…"); - a "should be verified" / "double-check that…" note. If you are not certain something is broken, it is a PASS — omit it entirely. An entry you describe as acceptable still counts against the score, so describing a non-issue is worse than saying nothing.`); parts.push(`## Original Request ${ctx.originalPrompt}`); if (ctx.contract) { parts.push(`## Data Contract (JSON Schema → TypeScript types) The component's boilerplate was generated from this contract. All types are compiler-enforced: - \`props\` → \`interface Props\` — every field must be rendered in the UI - \`actionSpec\` (flat map keyed by action name) → \`useAction(name)\` hooks — each must be wired to a UI element (button, form, etc.) - \`actionSpec[name].nextStep\` (optional) — names an agent-side MCP tool the agent SHOULD invoke on its next turn after the action fires. Surfaces as advisory event metadata; the component does NOT call the named tool. Use it to judge whether button labels/icons match the underlying action's intent. - \`streamSpec\` (flat map keyed by channel name) → \`useStream(name)\` hooks — each must be consumed with null guard (\`.latest && ...\`) - \`agentCapabilities.tools\` is a READ-ONLY catalog — the AGENT invokes these. The component never calls them. References surface only via \`actionSpec[*].nextStep\` and \`streamSpec[*].source.tool\`. Do NOT flag missing component-side calls for these. - \`clientCapabilities.gadgets\` → a map keyed by npm PACKAGE name; each package maps export names to a descriptor. Two kinds, both contract features that MUST be used: • Built-in browser capabilities (\`useGeolocation\`, \`useCamera\`, \`useClipboardWrite\`, \`useMicrophone\`, \`useFilePicker\`, \`useClipboardPaste\`, \`useNotifications\`) — imported from \`@ggui-ai/gadgets\`. • Registered third-party gadgets — hooks or components — imported from their OWN package (e.g. \`import { useChartTheme } from '@ggui-samples/gadget-chart'\`). The boilerplate emits that import. Any export listed under \`clientCapabilities.gadgets\` IS part of the contract — NEVER flag it as "not part of the contract". \`\`\`json ${JSON.stringify(ctx.contract).slice(0, 6000)} \`\`\``); } if (ctx.designSystemSummary) { parts.push(`## Design System ${ctx.designSystemSummary}`); } parts.push(`## Design System Rules (important for evaluation) - Spacing props (\`gap\`, \`padding\`, \`margin\`) take a t-shirt-scale name: \`gap="md"\`, \`padding="lg"\` (\`none|xs|sm|md|lg|xl|2xl\`). **These ARE design tokens** — each resolves to a \`--ggui-spacing-*\` variable. A raw \`var(--ggui-spacing-*)\` string is an accepted escape hatch. NEVER flag a scale name as "hardcoded". Only a numeric prop (\`padding={24}\`) or a raw CSS length (\`gap="13px"\`) bypasses the scale and should be warned. - All colors must use CSS variables: \`color="var(--ggui-color-primary-600)"\` — hardcoded #hex or rgb() is a fail - Component props that take enum/scale values — \`variant="primary"\`, \`size="lg"\`, \`gap="md"\`, \`padding="lg"\`, \`shadow="md"\`, \`radius="lg"\`, \`tone="muted"\`, \`surface="accent"\` — are all design system tokens and are always valid - One \`export default function Component\` — helper components as named functions above it are fine ## Evaluation Criteria Reference ### Tier 1 — Critical (blocks delivery) **functionality**: Does the component implement ALL features in the original request? - Check every noun/verb in the request against the rendered output - Missing feature = fail, even if other features work perfectly - Partial implementation = fail (e.g., search UI exists but doesn't filter) **crash**: Are there runtime crash scenarios? - Uninitialized state used in render (e.g., items.map when items could be undefined) - Missing event handlers referenced in JSX - Destructuring undefined objects - Array access without bounds checking - Missing null/undefined guards before .map(), .filter(), .length ### Tier 2 — Quality (fail if severe, warn if improvement) **interactivity**: Does it have interactive elements appropriate for its purpose? - Forms should have submit buttons - Lists should be navigable - Editable fields should have save/cancel - Interactive components should have hover/focus feedback **accessibility**: Semantic HTML, labels, alt text, keyboard navigation, ARIA state. - Form inputs must have associated labels (label prop or aria-label) - Images need alt text - Interactive elements need keyboard support - Interactive controls expose their state (aria-checked / aria-selected / aria-expanded / aria-pressed), never styling alone - Proper heading hierarchy (h1 > h2 > h3) **layout**: Spacing, alignment, visual grouping. - Consistent spacing between sections - Related items grouped visually - No overlapping or cramped elements - Proper use of containers and cards for grouping **loading**: Loading/error/empty states for dynamic data. - Components receiving async data should show loading state - Error boundaries or error messages for failure cases - Empty states ("No items found") when data arrays could be empty **visual**: Design system consistency. - Uses CSS variables (var(--ggui-*)) for colors, not hardcoded values - Consistent use of spacing tokens - Proper use of component variants (primary, outline, ghost) - Shadow, border-radius from design system tokens ## Primitive Accessibility (built-in — do NOT flag as missing) ggui design-system primitives ship their own ARIA. The code you are reading imports them from \`@ggui-ai/design\`; the source does NOT show their internal markup. NEVER report a primitive below as missing a role, aria-*, label, or keyboard support — it is already there: - \`Input\` / \`Select\` / \`TextArea\` — render a real \`