← Terug naar blog
AI Engineering
18 november 2025

AI-evaluatie harness naast productiecode

Hoe je testcases aanmaakt vanuit echte gesprekken, agent-antwoorden replayed en regressies opspoort — zonder aparte testinfrastructuur
Aron Heesakkers
Aron HeesakkersFullstack Developer & AI Engineer

Een unit test die controleert of extractKeywords("verlofaanvraag procedure") de juiste array retourneert, is nuttig. Maar hij vertelt je niet of de agent op de vraag "hoe vraag ik verlof aan?" een goed antwoord geeft na jouw laatste aanpassing aan de RAG-context.

Dat is het fundamentele probleem met AI-evaluatie: de kwaliteit zit in de output van het hele systeem, niet in de losse componenten. En die output verandert als je de retrieval-logica aanpast, een kennisdocument toevoegt, of de systeem-prompt herformuleert.

Onze aanpak: testcases importeren vanuit echte ai_call_logs, ze door dezelfde agent-pipeline draaien, en de output opslaan naast de vorige run.

Testcase-structuur

Een testcase bevat alles wat de agent-turn nodig heeft om de situatie te reproduceren:

export interface AiTestCase {
  id: string;
  name: string;
  person_name: string;
  department: string | null;
  conversation_history: Array<{ role: "user" | "assistant"; content: string }>;
  inbound_text: string; // de vraag die de agent moet beantwoorden
  source_log_id: string | null; // link naar de originele call_log-entry

  // ingevuld na een testrun:
  last_run_at: string | null;
  last_run_response: string | null;
  last_run_latency_ms: number | null;
  last_run_tokens: number | null;
  last_run_call_log_id: string | null;
}

De source_log_id is de link naar de originele productie-aanroep. Daarmee kun je later vergelijken: "wat gaf de agent toen, en wat geeft hij nu?"

Testcases maken vanuit productielog

De handigste manier om testcases te maken is niet handmatig — het is vanuit negatieve feedback. Als een medewerker een duim omlaag geeft, kun je die feedback-entry direct omzetten naar een testcase:

export const searchCallLogs = createServerFn({ method: "GET" })
  .inputValidator(z.object({ q: z.string().default("") }))
  .handler(async ({ data }) => {
    return sb
      .from("ai_call_logs")
      .select("id, user_message, response_text, agent, latency_ms, created_at")
      .ilike("user_message", `%${data.q}%`)
      .order("created_at", { ascending: false })
      .limit(50);
  });

Je zoekt de relevante call_log op, klikt op "importeer als testcase", en de conversatiegeschiedenis wordt automatisch ingeladen vanuit de database:

export const fetchConversationHistoryForTest = createServerFn({
  method: "GET",
}).handler(async ({ data }) => {
  const { data: msgs } = await sb
    .from("messages")
    .select("from_self, text, created_at")
    .eq("conversation_id", data.conversationId)
    .lt("created_at", data.beforeTimestamp) // alleen berichten vóór de testboodschap
    .order("created_at", { ascending: false })
    .limit(15);

  return msgs.reverse().map((m) => ({
    role: m.from_self ? "assistant" : "user",
    content: m.text,
  }));
});

Het resultaat is een testcase die exact de situatie reproduceert: welke persoon, welke afdeling, welke gesprekshistorie, welke inkomende vraag.

Een testcase draaien

Het draaien van een testcase roept dezelfde handleAgentTurn-functie aan als productie — niet een gemockte versie:

export const runTestCase = createServerFn({ method: "POST" }).handler(
  async ({ data }) => {
    const start = Date.now();
    const result = await handleAgentTurn({
      orgId: org_id,
      conversationId: `eval-${testCase.id}`, // stabiele fake ID — raakt geen echte data
      personId: testCase.person_id,
      personName: testCase.person_name,
      department: testCase.department,
      inboundText: testCase.inbound_text,
      overrideHistory: testCase.conversation_history, // injecteer testhistorie
    });
    const latencyMs = Date.now() - start;

    // Sla resultaat op naast de testcase
    await sb
      .from("ai_test_cases")
      .update({
        last_run_at: new Date().toISOString(),
        last_run_response: result.reply,
        last_run_latency_ms: latencyMs,
        last_run_tokens: result.logParams.total_tokens,
        last_run_call_log_id: callLogId,
      })
      .eq("id", testCase.id);

    return {
      reply: result.reply,
      latency_ms: latencyMs,
      tokens: result.logParams.total_tokens,
    };
  },
);

De overrideHistory-parameter injecteert de opgeslagen conversatiehistorie in de agent-turn, zodat de agent dezelfde context heeft als tijdens de originele productie-aanroep.

De conversationId is een stabiele fake ID (eval-{testCase.id}) zodat testdraaien geen echte conversaties aanraakt en idempotent zijn.

Negatieve feedback clusteren per intent

Naast handmatige testcases biedt de harness een automatische clustering van negatieve feedback per intent. Dat laat zien welke type vragen structureel slecht gaan:

export const listIssuesByIntent = createServerFn({ method: "GET" }).handler(
  async ({ data }) => {
    const since = new Date(
      Date.now() - data.days * 24 * 60 * 60 * 1000,
    ).toISOString();

    // Haal alle negatieve feedback op in het tijdvenster
    const { data: rows } = await sb
      .from("ai_feedback")
      .select("id, comment, auto_analysis, message_id")
      .eq("rating", "negative")
      .gte("created_at", since);

    // Join op ai_call_logs om de intent te achterhalen
    const intentMap = new Map<string, string | null>();
    await sb
      .from("ai_call_logs")
      .select("message_id, intent")
      .in("message_id", rows.map((r) => r.message_id).filter(Boolean))
      .then(/* vul intentMap */);

    // Groepeer per intent, tel en retourneer diagnose van meest recente feedback
    return groupByIntent(rows, intentMap);
  },
);

Het resultaat: "knowledge_lookup — 12 negatieve beoordelingen de afgelopen 7 dagen, meest recente diagnose: retrieval mist relevante documenten over verlofbeleid."

Feedback replayed via rerunFeedback

Als je een aanpassing hebt gedaan en wilt weten of hij het probleem oplost, kun je elke feedbackrij direct opnieuw door de agent draaien:

export const rerunFeedback = createServerFn({ method: "POST" }).handler(
  async ({ data }) => {
    const history = data.conversationId
      ? await fetchHistoryInternal(data.conversationId, data.beforeTimestamp)
      : [];

    const result = await handleAgentTurn({
      orgId: data.orgId,
      conversationId: `rerun-${Date.now()}`,
      personId: data.personId,
      inboundText: data.inboundText,
      overrideHistory: history,
    });

    return { reply: result.reply, latency_ms: result.logParams.latency_ms };
  },
);

Dit is de kernfunctionaliteit: je maakt een aanpassing, drukt op "herdraai", en ziet direct wat de agent nu zou antwoorden op hetzelfde bericht.

Wat dit evalueert en wat niet

De harness evalueert of de agent-pipeline als geheel de gewenste output produceert: retrieval, intent-routing, systeem-prompt, en LLM-completion samen. Dat is wat unit tests niet kunnen.

Wat het niet evalueert: of het antwoord objectief "juist" is. Dat oordeel blijft menselijk. De harness geeft je het gereedschap om snel te vergelijken — het oordeel of een antwoord beter of slechter is dan de vorige versie, maak je zelf.

In de praktijk gebruiken we de harness voor twee dingen: regressie-detectie (controleer na elke grote aanpassing of bestaande testcases nog de verwachte output geven) en diagnose (begrijp waarom een specifiek bericht een slecht antwoord produceerde door de call_log te bekijken). Vertel ons over je AI-evaluatie-uitdaging als je wilt sparren over de aanpak.


Project bespreken?

Heb je een use case waar je over twijfelt?

We praten graag over of een AI-aanpak past — en zeggen het eerlijk als het beter zonder kan. Vaste prijs, afgesproken scope.
Plan een gesprek
Probeer AI