{"slug": "monitoring-ai-quality-evaluation-pipelines-for-non-deterministic-products", "title": "Monitoring AI Quality: Evaluation Pipelines for Non-Deterministic Products", "summary": "A developer outlined an evaluation pipeline for non-deterministic AI features that generates real outputs, uses a second LLM as a judge to grade them, and alerts teams in Slack when quality drops, with cron jobs extending the checks beyond CI to catch silent model updates. The approach pairs a fixed golden dataset of realistic, anonymized fixtures with three tiers of checks—deterministic format validation, code-based metrics, and LLM-as-a-judge scoring—illustrated through a meeting-assistant summarization feature. The core argument is that AI quality is a distribution rather than a single value, so teams should test properties of good answers instead of exact outputs.", "body_md": "Shipping an AI feature is the easy part. Knowing that it still works next month is the hard one.\n\nIn a traditional application, the same input always gives you the same output. That's why tests work so well: you write `expect(sum(2, 2)).toBe(4)` once, and it protects you forever. AI features break that contract. Send the same input to an LLM twice and you get two different answers. Both may be good. One may be a bit worse. And one day, after a prompt tweak or a silent model update, all of them may be wrong, with no error in the logs.\n\nIn this article we'll walk through how to monitor the quality of an AI feature: a pipeline that generates real outputs, asks a second model to grade them, and tells the team in Slack when quality drops. Then we'll go one step further and run it on a schedule with cron jobs, so you also catch the problems that don't come from your own deploys.\n\n**How to read this article:** if you're on the product or leadership side, the first sections explain the problem and what to measure, with no code required. If you're a developer, the implementation and the cron examples are in \"Building the Pipeline\" and \"Beyond CI\".\n\nTo keep things concrete, we'll use one example throughout the article: a **meeting assistant**. It reads the transcript of a recorded call and writes a structured summary: key decisions, action items with owners, and highlights tied to moments in the recording. Each team picks a summary template (sales call, standup, job interview, customer support and so on), so the same call should produce a different summary depending on that choice. That's a lot of ways to be almost right.\n\nWith AI, quality is not a value, it's a distribution. Your feature doesn't produce \"the right answer\"; it produces a range of answers, and most of them are good. The job is to keep that range where you want it.\n\nThink of it like a restaurant chain. The same dish is cooked by different cooks, in different kitchens, with ingredients from different suppliers. Nobody tastes every plate, but someone tastes a sample against the recipe on a regular schedule. That's what an evaluation pipeline does for an AI feature.\n\nThe tricky part is that quality can change even when nobody touches the code. There are three common sources of change:\n\nNone of these throws an error. The output just gets a little worse, and your users notice before you do. For an AI feature, that's the real cost: not a crash, but **trust**. A feature that is great 90% of the time and strange the other 10% teaches users to double-check everything, and then it stops saving them time.\n\nAn evaluation pipeline gives you three things:\n\nYou stop checking for *the* answer and start checking for **properties** of a good answer. Instead of `expect(output).toBe(...)`, you ask: is it well-formed? Does it cover the whole call? Is it faithful to the transcript? Each question needs a different kind of check, and they get more expensive as they get smarter.\n\n| Check | What it answers | Cost | Example | \n|---|---|---|---|\n| Deterministic | Is the output well-formed? | Free, instant | The summary is valid JSON with every required section | \n| Metric (code) | Does it have a measurable property? | Cheap | Highlights are spread across the whole recording | \n| LLM-as-a-judge | Is it accurate, complete and useful? | One extra LLM call per sample | A 1 to 100 score for each metric you define, like accuracy or clarity | \n\nAll of them run on top of the same foundation:\n\nA **golden dataset** is a fixed set of realistic inputs that represent how your customers use the feature. For our meeting assistant, that could be 20 fixtures: 15 call transcripts and 5 written meeting notes, covering every summary template. A second set of 5 timestamped recordings is used for the coverage check. Use synthetic or anonymized data, never raw customer content.\n\nBecause the inputs never change, the scores are comparable over time. If last week's average was higher than today's, something on our side or the provider's side changed.\n\nSome qualities can be measured with plain code, no AI needed. A good one for our example is **coverage**. If the summary only cites moments from the first ten minutes of a one-hour call, the reader gets half the picture. So we split the recording into bins of about four minutes and measure how evenly the summary's timestamps spread across them. A score of 0 means everything is bunched in one place; 1 means perfectly even.\n\nFor the things code can't measure, like \"was this decision really made in the call?\", you use a second model as a **judge**. It receives the source, the generated output and a rubric, and grades the output.\n\nThe most important part is the rubric, and it should be **yours**. Start by writing down the metrics that define a good output for your product. For our meeting assistant, they could be:\n\nA support bot would have different ones, like tone, policy compliance or whether the issue was solved. Then ask the judge to return a **score from 1 to 100 for each metric**, with a one-sentence reason for each score. Run it with temperature 0 to keep it as stable as possible.\n\nWhy a separate score per metric instead of one grade? Because it lets you decide what \"good enough\" means for each metric on its own, as we'll see in the thresholds.\n\nThe judge is also an LLM, so treat its scores as measurements with some noise, like a thermometer that can be off by a degree. Before trusting the numbers, compare a few judge scores with a human expert's opinion, and adjust the rubric until they mostly agree.\n\nA score is only useful when it's compared against something. With a 1 to 100 score per metric, you set a **threshold for each metric**, based on how much that metric matters to your product:\n\n| Metric | Threshold | Why | \n|---|---|---|\n| Accuracy | 90 | An invented decision destroys trust | \n| Owners | 85 | Action items without an owner don't get done | \n| Completeness | 80 | A missed item is bad, but easy to add by hand | \n| Clarity | 70 | Style matters, but less than the facts | \n\nThese numbers are only an example. Choosing them is a product decision, not an engineering one: a healthcare product may want accuracy at 98, while a brainstorming tool may be fine with 70. Together, the thresholds are the quality level you expect from the feature, written down.\n\nThere are two ways to apply them, and you'll probably want both:\n\nCode metrics, like coverage, work the same way: decide how even the spread must be for your product, and fail the run below that.\n\nA natural next step for **consistency** is to run the same input several times and measure the spread of the scores. A feature with an average of 80 and a small spread is a better product than one with the same average that swings between 60 and 95.\n\nThe pipeline itself doesn't need to be complex. It's a script that runs the golden set against a real environment, calling the same endpoints the product uses, so it tests the whole stack at once: prompts, model, parsing and response format. Run it as a CI job, like a GitHub Action, every time something that can change quality changes: a prompt, a model version or the code around them.\n\n![Evaluation pipeline: a prompt or model change, or a scheduled run, runs the golden set; a rubric check and a coverage check run in parallel; the results go to the job summary and to a Slack message that is green only when both checks pass]\n\nThe two checks run side by side, and a failure in either one turns the Slack message red.\n\nThe core of the pipeline is a Python script, `run_evals.py`. For each fixture it makes two calls to OpenAI: one to generate the summary with the same prompt and model the product uses, and one to ask the judge to grade it. Here is a simplified version:\n\n``` python\nimport asyncio\nimport sys\n\nfrom openai import AsyncOpenAI\n\nclient = AsyncOpenAI()  # reads OPENAI_API_KEY from the environment\n\nMODEL = \"gpt-6-luna\"  # the same model the product uses\nTHRESHOLDS = {\"accuracy\": 90, \"owners\": 85, \"completeness\": 80, \"clarity\": 70}\n\nasync def generate_summary(source):\n    # Step 1: generate the summary with the product's own prompt\n    response = await client.chat.completions.create(\n        model=MODEL,\n        messages=[\n            {\"role\": \"system\", \"content\": SUMMARY_PROMPT},\n            {\"role\": \"user\", \"content\": source},\n        ],\n    )\n    return response.choices[0].message.content\n\nasync def judge_summary(source, summary):\n    # Step 2: ask the judge for a 1-100 score per metric\n    response = await client.chat.completions.create(\n        model=MODEL,\n        reasoning_effort=\"none\",  # temperature only works with reasoning off\n        temperature=0,\n        response_format={\"type\": \"json_object\"},\n        messages=[\n            {\"role\": \"system\", \"content\": JUDGE_PROMPT},\n            {\"role\": \"user\", \"content\": f\"Source:\\n{source}\\n\\nSummary:\\n{summary}\"},\n        ],\n    )\n    # JudgeResult validates the answer, we'll see it in the next section\n    return JudgeResult.model_validate_json(response.choices[0].message.content)\n\nasync def process_fixture(fixture):\n    source = fixture[\"source\"]  # a transcript or written notes\n    summary = await generate_summary(source)\n    return await judge_summary(source, summary)\n\nasync def run_all():\n    fixtures = load_fixtures()\n    results = await asyncio.gather(*(process_fixture(f) for f in fixtures))\n\n    failed = []\n    for metric, threshold in THRESHOLDS.items():\n        average = sum(result.scores[metric] for result in results) / len(results)\n        status = \"PASS\" if average >= threshold else \"FAIL\"\n        print(f\"{metric}: {average:.1f} (threshold {threshold}) {status}\")\n        if average < threshold:\n            failed.append(metric)\n\n    sys.exit(1 if failed else 0)\n\nasyncio.run(run_all())\n```\n\nIf you read our article on [async tasks in arrays](https://dev.to/quave/improving-javascript-performance-understanding-async-tasks-in-arrays-4el4), `asyncio.gather` will look familiar: it's Python's `Promise.all`. With the SDK's async client, all fixtures run in parallel. LLM calls are slow, so running them one by one would make the pipeline far slower.\n\n`SUMMARY_PROMPT` and `MODEL` should be exactly what the product uses, and `JUDGE_PROMPT` is the rubric from the next section. To keep the example short, step 1 calls OpenAI directly. If your feature runs behind your own API, call that API instead, so each run also tests what's around the model, like how the prompt is built and how the answer is parsed.\n\nA complete version should also keep failed requests apart from low scores. A request that errors is reported by name, and any failure fails the run, because \"the AI service returned a 500\" and \"the AI got worse\" are different problems.\n\nThe judge's prompt lists your metrics and asks for a strict JSON answer. A trimmed version for our example:\n\n```\nYou are evaluating a meeting summary against the call transcript.\nScore each metric from 1 to 100 and explain each score in one sentence.\n\n- accuracy: every decision and action item really happened in the call\n- completeness: no decision or action item was left out\n- owners: every action item has a person responsible for it\n- clarity: someone who missed the meeting understands the summary\n\nAnswer only with JSON:\n{\"scores\": {\"accuracy\": <1-100>, ...}, \"reasons\": {\"accuracy\": \"<one sentence>\", ...}}\n```\n\nValidate the answer before using it. The judge is an LLM too, so sometimes it returns a score out of range, or something that isn't JSON at all. With Pydantic, that becomes an error instead of a made-up number:\n\n``` python\nfrom typing import Annotated\n\nfrom pydantic import BaseModel, Field\n\nScore = Annotated[int, Field(ge=1, le=100)]\n\nclass JudgeResult(BaseModel):\n    scores: dict[str, Score]  # one 1-100 score per metric\n    reasons: dict[str, str]  # one sentence per metric\n```\n\nA score per metric tells you **what kind** of problem you have. Low accuracy usually means the AI is inventing things that didn't happen in the call. Low completeness means items were skipped. Low clarity means the summary is vague. Each one points to a different fix, and the reasons tell you where to look.\n\nVersion the judge's prompt too, just like the prompts it evaluates; a prompt management tool like Langfuse makes that easy. And if you want one headline number for a dashboard, the average of your metrics works well.\n\nA check that nobody sees is a check that doesn't exist. Every run publishes its results in three places:\n\nEvaluation pipelines are not immune to the problem they solve. They call slow, non-deterministic services, so they time out, the judge occasionally returns something unparseable, and a deploy may not be live yet when the run starts. When that happens often enough, teams tend to switch the automatic run to manual, and a manual check is one that people forget to run.\n\nThe lesson: **separate \"the pipeline broke\" from \"quality dropped\"**. They need different alerts and often different owners. If every red message might be noise, people stop reading them, and then the pipeline protects nobody.\n\nA pipeline that runs after a deploy answers one question: *did we break something?* But remember the three sources of change. Two of them, the model provider and your customers' inputs, never come with a deploy. A model can change on a Tuesday afternoon while your team is in a meeting. To catch that, the evaluation has to run on a clock, not on a commit.\n\nThere are two kinds of scheduled runs, and they answer different questions:\n\n| Scheduled run | Inputs | Question it answers | \n|---|---|---|\n| Golden set on a cron | The same fixtures every time | Did something change under us? | \n| Production sampling | A random sample of real outputs | How good is what customers actually get? | \n\nThe golden set is your **control group**: the inputs never change, so any change in the score comes from the system. Production sampling is your **reality check**: the inputs change every day, and that's the point.\n\nThe easiest step is to reuse what you already have. Take the same validation script and add a `schedule` trigger:\n\n```\non:\n  schedule:\n    - cron: \"0 11 * * 1-5\" # weekdays at 11:00 UTC\n  workflow_dispatch:\n```\n\nA few things to keep in mind with scheduled GitHub workflows:\n\nThe golden set tells you if the system changed. It doesn't tell you how the AI is doing with the inputs customers send today. For that, you sample real outputs and grade them with the same judge.\n\nHere's what that job can look like in Node, using `node-cron` for the schedule and `p-map` to limit how many judge calls run at once:\n\n``` python\nimport cron from 'node-cron';\nimport pMap from 'p-map';\n\nconst SAMPLE_SIZE = 20;\nconst MAX_DROP = 5; // points below the 7-day average\nconst SIX_HOURS = 6 * 60 * 60 * 1000;\n\nconst average = (values) =>\n  values.reduce((sum, value) => sum + value, 0) / values.length;\n\ncron.schedule('0 */6 * * *', async () => {\n  // 1. Sample real outputs generated since the last run\n  const samples = await AiOutputs.aggregate([\n    { $match: { createdAt: { $gte: new Date(Date.now() - SIX_HOURS) } } },\n    { $sample: { size: SAMPLE_SIZE } },\n  ]).toArray();\n\n  if (!samples.length) return;\n\n  // 2. Grade them with the same judge and metrics used in CI, 4 at a time\n  const results = await pMap(samples, (sample) => judge(sample), {\n    concurrency: 4,\n  });\n\n  // 3. Store the run, so we can see trends and not only pass/fail\n  const metrics = Object.keys(results[0].scores);\n  const averages = Object.fromEntries(\n    metrics.map((metric) => [\n      metric,\n      average(results.map((result) => result.scores[metric])),\n    ])\n  );\n  await EvalRuns.insertOne({\n    createdAt: new Date(),\n    averages,\n    promptVersion: samples[0].promptVersion,\n    model: samples[0].model,\n    scores: results.map(({ outputId, scores }) => ({ outputId, scores })),\n  });\n\n  // 4. Alert on any metric that dropped below its recent baseline\n  const baseline = await getRollingAverages({ days: 7 });\n  const dropped = metrics.filter(\n    (metric) => averages[metric] < baseline[metric] - MAX_DROP\n  );\n  if (dropped.length) {\n    await notifySlack(\n      dropped\n        .map(\n          (metric) =>\n            `${metric}: ${averages[metric].toFixed(1)} vs 7-day average ${baseline[metric].toFixed(1)}`\n        )\n        .join('\\n')\n    );\n  }\n});\n```\n\n`AiOutputs`, `EvalRuns`, `judge`, `getRollingAverages` and `notifySlack` stand for your own collections and helpers. The shape is what matters: **sample, grade, store, compare**.\n\nSome points that are easy to miss:\n\n`node-cron` fires on every one of them, and you pay for the same evaluation several times. Use a scheduler that runs once, like a Kubernetes CronJob, or a package with a shared lock, like SyncedCron in Meteor.\nA fixed threshold catches big drops. But if accuracy goes from 97 to 91, it still passes a threshold of 90, and something clearly got worse. Comparing each run against a rolling baseline, like the 7-day average in the example above, catches the slow slides that a fixed number misses.\n\nAnd remember the lesson from the previous section: give \"the job failed to run\" its own alert. A monitor that fails silently looks exactly like a monitor that says everything is fine.\n\nOne number is enough to start, but a few more turn a red light into a diagnosis. These are the metrics we find useful, and who usually reads each one:\n\n| Metric | What it tells you | Who reads it | \n|---|---|---|\n| Average judge score | Overall quality, the headline number | Product, leadership | \n| Score vs. 7-day baseline | Whether quality is sliding, even above the threshold | Product, leadership | \n| Score per scenario (e.g. per summary template) | Where the AI is weak, and which customers feel it | Product | \n| Score per metric (e.g. accuracy, clarity) | What kind of problem to fix | Engineering | \n| Spread across repeated runs | Consistency: how much the answer varies for the same input | Product, engineering | \n| Coverage and other code metrics | Measurable properties the judge can miss | Engineering | \n| Latency per call | How long users wait for the AI | Product, engineering | \n| Failed runs and request errors | Whether the monitor itself is healthy | Engineering | \n| Tokens per run | What the feature costs, and what monitoring it costs | Leadership | \n\nPut the numbers where people already look. For leadership, that can be a single daily line in Slack: the average, the trend against the baseline, passed or failed. For engineers, it's the job summary with each metric's score and a link to the generated outputs. The goal is that nobody has to remember to go check a dashboard.\n\nAI features don't fail like regular code. They don't crash; they drift. A prompt change, a model update or a new kind of input can make the output a little worse, and no log will tell you. The only way to know is to keep measuring.\n\nThe good news is that the tools for this are the ones you already use: a test script, a CI workflow, a cron job and a Slack channel. What changes is what you assert. Not the exact answer, but the properties of a good one: well-formed, complete, faithful to the source, and good enough by a rubric that your team agreed on.\n\nIf you want to start this week, here's the path we'd follow again:\n\nYour AI feature will never give the same answer twice. With an evaluation pipeline watching it, it can still give the same quality.", "url": "https://wpnews.pro/news/monitoring-ai-quality-evaluation-pipelines-for-non-deterministic-products", "canonical_source": "https://dev.to/isaque_silvachaves/monitoring-ai-quality-evaluation-pipelines-for-non-deterministic-products-355j", "published_at": "2026-10-20 19:12:21+00:00", "updated_at": "2026-10-20 19:18:56.504436+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "ai-tools", "mlops", "ai-agents"], "entities": ["Slack"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/monitoring-ai-quality-evaluation-pipelines-for-non-deterministic-products", "markdown": "https://wpnews.pro/news/monitoring-ai-quality-evaluation-pipelines-for-non-deterministic-products.md", "text": "https://wpnews.pro/news/monitoring-ai-quality-evaluation-pipelines-for-non-deterministic-products.txt", "jsonld": "https://wpnews.pro/news/monitoring-ai-quality-evaluation-pipelines-for-non-deterministic-products.jsonld"}}