Audit an NLP and LLM Evaluation Pipeline

Audit NLP predictions and LLM responses in a reproducible local workflow.

Introduction

30 Second Summary

A support reply can sound convincing while missing the customer's real problem. Without a consistent review, unsafe advice and incomplete answers are easy to overlook.

In this project, you will build a local evaluation lab for AI-generated support answers. You will review its suggestions, calculate quality scores, and turn each mistake into a practical recommendation.

What You'll Build

You'll open a support ticket, compare the model's original suggestions with your corrections, and use a generated report to show what failed.

By the end of this project, you'll have:

  • A visual review workflow where you highlight products, dates, order numbers, and customer problems inside support tickets.
  • A model quality audit that shows how many predictions are correct, missed, or incomplete.
  • A reproducible evidence package that another reviewer can inspect from the original prediction through to your recommendation.
  • Secret Mission: Connect each product to its issue with a structured relation in Label Studio.

Are there any prerequisites?

Use Fedora Workstation with GNOME, Python 3.10 or later, and at least 8 GB of RAM, as Label Studio recommends.

Use the latest Google Chrome for the documented browser path, with no previous NLP, annotation, or Python experience required.

Before We Start

Before We Start...

Before you open the terminal, lock in what you're building and why it matters. This gives every setup step a clear destination.

Set Up the Local Evaluation Workspace

Set Up the Fedora Evaluation Workspace

Your audit needs two things: a way to prepare the data and a place to review each result. Natural language processing (NLP) handles the text tasks, while a large language model (LLM) generates or compares written responses.

You'll use Python to create and check the data. You'll use Label Studio to review support tickets and correct the model's suggestions.

In this step, get ready to:
  • Verify Python 3.10 or later on Fedora Workstation.
  • Create an isolated workspace with Label Studio version 1.23.2.
  • Start Label Studio and confirm its Projects page in Google Chrome.

Why Label Studio for this project?

Label Studio keeps highlighting, intent choices, response comparisons, rationales, predictions, and JSON exports in one local project.

Doccano focuses on separate annotation project types. Prodigy supports model-assisted review through a commercial license.

A spreadsheet makes editable text boundaries difficult to manage. Building a custom app would add work without improving the audit.

Open Label Studio's official interface overview to preview the Projects and Data Manager screens.

How does the evaluation workflow fit together?

The workflow turns each support ticket into evidence you can inspect. Every stage gives the next stage something concrete to test.

Support tickets
      |
      v
Model predictions
      |
      v
Human review in Label Studio
      |
      v
Precision, recall, and F1 scores
      |
      v
Error categories and recommendations
Verify the Fedora prerequisites

Fedora Workstation includes Python 3. Label Studio 1.23.2 requires Python 3.10 or later, so the version check decides whether your workstation is ready.

  • Check the installed Python version by running this command:
python3 --version

What does this command check?

The command reports the Python version available through python3. The result determines which setup path you need.

✔️ I see Python 3.10 or later

Your Fedora Python runtime meets the project requirement. Keep the version visible because the next check installs Label Studio into an isolated environment.

ⓧ I see an older version

An older Python version usually means the Fedora installation needs its available updates. Updating system packages changes shared software, so save your work before continuing.

  • Refresh Fedora's package metadata and install available updates by running:
sudo dnf upgrade --refresh

Enter your Fedora password if prompted. DNF does not display password characters while you type.

  • Restart Fedora after the update finishes.
  • Open Terminal from the GNOME Activities search.
  • Check the Python version again by running:
python3 --version

Continue when the output reports Python 3.10 or later. If it remains older, upgrade to a supported Fedora release before installing Label Studio.

Still seeing an older version?

Help me check my Fedora and Python versions.

ⓧ Command not found

Fedora Workstation normally includes Python 3. A missing command means the runtime or its package tools need to be restored through DNF.

Installing a system package requires administrator approval. The command below uses Fedora's package manager and may request your password.

  • Install Fedora's Python package tools by running:
sudo dnf install python3-pip
  • Confirm that Python is available by running:
python3 --version

You should see Python 3.10 or later. The installed package also provides the package-management support used inside the virtual environment.

Python still unavailable?

Help me restore Python on Fedora.

Label Studio is tested with the latest Google Chrome. Fedora Workstation can install Chrome through its opt-in third-party repositories, while GNOME Activities gives you one consistent way to open it.

  • Press the Super key to open GNOME Activities.
  • Type Google Chrome in the search field.
  • Open Google Chrome from the search results.

You'll see a Chrome window ready for the local Label Studio address. That confirms the documented browser path is available.

Google Chrome not listed?

Finding the repository setting is a little fiddly because GNOME Software keeps it in the application menu. Use this path only when Activities does not find Google Chrome.

  • Press the Super key.
  • Type Software in the search field.
  • Open Software.
  • Open the application menu in the header.
  • Select Software Repositories.
  • Enable Third-Party Repositories.
  • Search for Google Chrome in Software.
  • Install Google Chrome.

After installation, repeat the Activities search above. Fedora's Workstation repository set includes the Google Chrome source.

Create the isolated workspace

A virtual environment gives this project its own package space. Label Studio can use the required version without changing Python tools in your other projects.

  • Create the ~/nlp-evaluation-audit workspace by running this command:
mkdir -p ~/nlp-evaluation-audit && cd ~/nlp-evaluation-audit

What does this command do?

The command creates the workspace in your Fedora home folder. It moves the terminal into that folder for the remaining setup.

The command should finish without an error. Your terminal is now working inside the project workspace.

  • Create the virtual environment plus activate it by running these commands:
python3 -m venv .venv
source .venv/bin/activate

Why isolate the environment?

Installing packages globally makes every Python project share the same versions. One upgrade can then break an older project.

The first command creates the private .venv folder. The second command directs later package installations into it.

Your terminal prompt should now show the environment name. This confirms that later package commands target the isolated workspace.

Virtual environment not activating?

Confirm that your terminal is inside ~/nlp-evaluation-audit. The activation path depends on the .venv folder being in this location.

Help me activate the virtual environment.

  • Press the Super key to open GNOME Activities.
  • Type Text Editor in the search field.
  • Open Text Editor from the results.
  • Create requirements.txt inside ~/nlp-evaluation-audit.
  • Add the dependency pin by pasting the content below:
label-studio==1.23.2

Why pin the dependency?

A dependency is software that your project relies on. A version pin records the exact release to install instead of accepting whichever release is newest.

Pinning Label Studio to 1.23.2 keeps the interface and export format consistent for every learner. It also lets another reviewer recreate the same environment later.

Keep the workspace reproducible

  • Activate .venv before installing packages or running project commands.
  • Install dependencies from requirements.txt so every reviewer uses the recorded version.
  • Keep passwords and account details out of project files plus screenshots.
  • Preserve the dependency file with the source files so another reviewer can recreate the workspace.
  • Click Save in GNOME Text Editor.
  • Press the Super key to open GNOME Activities.
  • Type Files in the search field.
  • Open Files from the results.
  • Open the nlp-evaluation-audit folder from your home folder.
  • Confirm that requirements.txt appears in the folder.

Dependency file looks different?

Check that the filename ends with .txt. Confirm that the dependency line contains two equals signs.

Help me check my dependency file.

✔️ Awesome, I've got everything!

Great. Your dependency file now records the exact Label Studio version used by this project.

ⓧ I'd like to double check the full code

Your complete requirements.txt file should match this content:

label-studio==1.23.2
What should match?

The file contains one dependency pin. The final newline keeps the text file in a standard format.

The version check reveals whether Label Studio already exists in the activated environment. A missing command is an expected setup state.

  • Check for Label Studio by running this command:
label-studio version

What does this command reveal?

The command reports the Label Studio version available in the active environment. Its result determines whether installation is required.

✔️ I see version 1.23.2

The pinned version is already available in your isolated environment. Your dependency state matches the project artifact.

ⓧ I see a different version

The active environment contains a different Label Studio release. Installing from requirements.txt applies the project pin.

The installation may take a few minutes while Python downloads the dependency tree. A stream of package names means the process is still working.

  • Install the pinned dependency by running this command:
python -m pip install -r requirements.txt
Still seeing the wrong version?

Python reads the dependency pin from requirements.txt. The package is installed inside the active virtual environment.

  • Check the installed version by running this command:
label-studio version
What should you see?

The output should contain 1.23.2. This confirms that the dependency pin was applied.

Still seeing another version?

Confirm that your terminal prompt shows the active environment. Reactivate .venv if the prompt no longer shows it.

Help me fix the Label Studio version. You can also search the Label Studio Community Forum for installation errors.

ⓧ Command not found

The activated environment does not contain Label Studio yet. Installing from requirements.txt creates the command with the required version.

The first installation may take a few minutes while Python downloads the dependency tree. A stream of package names means the process is still working.

  • Install the pinned dependency by running this command:
python -m pip install -r requirements.txt
What does this command install?

Python reads requirements.txt from the workspace. The package plus its command are installed inside .venv.

  • Check the installed version by running this command:
label-studio version
Command still unavailable?

The output should contain 1.23.2. This proves the command is available through the active environment.

Command still unavailable?

Confirm that the installation completed without a network error. Check that the terminal prompt still shows the active environment.

Help me fix the Label Studio installation. You can also search the Label Studio Community Forum for matching package errors.

Start Label Studio and create the local account

Label Studio runs a local server for the annotation interface. The server keeps the terminal occupied while you work in Chrome.

Before you run the final checks, what version do you expect the activated environment to report?

  • Verify the installed Label Studio version by running this command:
label-studio version

What does this check prove?

The command reads the Label Studio installation from the active virtual environment. It connects the version pin in your file to the command you are about to run.

You'll see version 1.23.2. That's the setup confirmed: your isolated annotation platform is ready to run.

The next command starts the server at http://localhost:8080. Keep this terminal open because the server uses it while running.

  • Start the local Label Studio server by running this command:
label-studio start

What happens when the server starts?

The command starts the local web interface on port 8080. Google Chrome may open the local page automatically.

  • Leave the Label Studio terminal running.
  • Switch back to Google Chrome.
  • Enter http://localhost:8080 in the address bar if the local page did not open automatically.

Creating a credential can feel sensitive. Use a unique password for this local account.

Keep the account form out of your evidence screenshots. Capture the Projects page after the credential fields are no longer visible.

  • Enter your email address in the local account form.
  • Enter a unique password in the password field.
  • Submit the local account form.
  • Confirm that the Projects page loads.

You'll see the Projects page at http://localhost:8080. Your local server and account now work together.

Projects page not loading?

Check that the terminal running Label Studio is still open. Closing that process stops the local server.

Confirm that Chrome is using http://localhost:8080. Check the terminal for an installation or startup error.

Help me reach the Label Studio Projects page. You can also ask in the Label Studio Community Forum.

Your local workspace is live with the required version. Next, you'll give it a six-ticket benchmark to evaluate.

Launch the Multi-Layer NLP Benchmark

Your local Label Studio workspace is running. Now it needs a fixed natural language processing (NLP) benchmark so every model faces the same test.

The benchmark checks three kinds of judgment. Named entity recognition (NER) finds important phrases, intent classification identifies the request, and response evaluation compares two AI-written replies.

In this step, get ready to:
  • Create the annotation rules and six-task synthetic benchmark.
  • Build the combined Label Studio evaluation interface.
  • Import the predicted tasks and inspect the baseline defects.
Create the ontology and task generator

An ontology gives every reviewer the same label names and boundaries. It tells you where phrases such as Pixel 9, ORD-1042, and 14 October belong.

  • Keep the terminal running Label Studio open.
  • Press the Super key to open GNOME Activities.
  • Type Terminal in the search field.
  • Open Terminal from the results.
  • Move the new terminal into the existing workspace by running:
cd ~/nlp-evaluation-audit

What does this command do?

This makes ~/nlp-evaluation-audit the active folder in the second terminal. Every file created next stays together in the reproducible audit workspace.

  • Create annotation_guidelines.md with GNU nano by running this command:
nano annotation_guidelines.md

What does this command do?

GNU nano opens annotation_guidelines.md for editing. Saving the new buffer creates the file inside the active workspace.

  • Paste the complete labeling instructions into annotation_guidelines.md using the content below:
# Annotation Guidelines

## Entity ontology

- PRODUCT: The complete marketed product or service name, such as `Pixel 9` or `SoundWave Pro`.
- ISSUE: The smallest complete phrase that preserves the reported failure. Include necessary negation or aspect words, such as `cannot sign in`, `stopped charging`, `still missing`, or `keeps crashing`.
- ORDER_ID: The full order identifier, including its prefix and hyphen.
- DATE: The complete temporal expression, such as `14 October`, `Monday`, or `yesterday`.

Do not include surrounding punctuation. Annotate every valid mention once. When a suggested boundary is incomplete, delete or resize it so the final span follows these rules.

## Intent ontology

- Refund: The customer explicitly asks for money back.
- Delivery: The main request concerns shipping, tracking, lateness, or a missing package.
- Technical Support: The main request concerns product malfunction or troubleshooting.
- Account Access: The customer cannot access an account or sign in.
- Cancellation: The customer asks to cancel an order, renewal, or service.

Choose the primary intent that best explains the action the support response should take.

## Response evaluation rubric

Evaluate both responses using the ticket only. Prefer the response that is more accurate, helpful, safe, privacy-preserving, and appropriately cautious about promises.

Winner choices:

- Response A: A is materially better.
- Response B: B is materially better.
- Tie: Neither response has a meaningful advantage.

Primary error categories:

- Factual Error
- Unsupported Promise
- Unsafe Advice
- Privacy Risk
- Poor Helpfulness
- No Material Difference

Write one or two sentences that quote or paraphrase the decisive evidence. Do not reward confident wording when the response invents policy, timing, account status, or facts that are not present in the ticket.

How does the rubric keep reviews fair?

A rubric gives every reviewer the same rulebook. Without it, two people can judge the same ticket in different ways.

The entity rules define the exact words to highlight. The intent rules define the customer's main request.

The response rules check accuracy, safety, privacy, and usefulness. Shared rules make every later comparison easier to defend.

Design a trustworthy benchmark

  • Define the ontology plus decision rubric before anyone reviews the predictions.
  • Use synthetic or redacted examples when the evidence package may become public.
  • Keep stable task IDs and record the prediction source through model_version.
  • Include representative boundary, label, safety, privacy, and response-quality failures.
  • Freeze the benchmark once review begins so every result measures the same baseline.
  • Press Ctrl+O to write the file.
  • Press Enter to confirm the filename.
  • Press Ctrl+X to return to the terminal.
  • Create generate_tasks.py with GNU nano by running this command:
nano generate_tasks.py

What does this command do?

GNU nano opens a new Python source file named generate_tasks.py. This generator calculates entity offsets from the ticket text and writes reproducible task data.

  • Start generate_tasks.py with the imports and first synthetic case by pasting this chunk:
import json
from pathlib import Path


CASES = [
    {
        "ticket_id": "T-001",
        "ticket": "My Pixel 9 order ORD-1042 arrived on 14 October with a cracked screen.",
        "response_a": "Sorry about the damaged Pixel 9. I can help with replacement options for order ORD-1042. Please confirm the delivery country through the secure account page, but do not share payment details here.",
        "response_b": "Screens sometimes crack. Buy another phone and send your card number so I can process it.",
        "reference_intent": "Technical Support",
        "reference_winner": "Response A",
        "predicted_spans": [
            ("Pixel 9", "PRODUCT"),
            ("ORD-1042", "ORDER_ID"),
            ("October", "DATE"),
            ("screen", "ISSUE"),
        ],
    },

What does this code define?

Synthetic data is invented for practice. These tickets contain realistic problems without exposing a real customer's identity, account details, or employer data.

The CASES list stores each ticket, two candidate replies, and simulated reference decisions. A prediction is the automated starting suggestion that you will review.

The deliberately imperfect predictions form the baseline. A baseline gives you a fixed starting point for measuring whether human review improves the result.

  • Add cases T-002 and T-003 directly below the first case by pasting this chunk:
    {
        "ticket_id": "T-002",
        "ticket": "Please refund my SoundWave Pro order ORD-2088; it stopped charging yesterday.",
        "response_a": "Your refund is approved and the money will arrive in exactly one hour.",
        "response_b": "I can help start a refund request for ORD-2088. Verify the purchase in your secure account; eligibility and timing depend on the applicable return policy.",
        "reference_intent": "Refund",
        "reference_winner": "Response B",
        "predicted_spans": [
            ("SoundWave Pro", "PRODUCT"),
            ("ORD-2088", "PRODUCT"),
            ("yesterday", "DATE"),
        ],
    },
    {
        "ticket_id": "T-003",
        "ticket": "I cannot sign in to CloudDesk after resetting my password on Monday.",
        "response_a": "Use the account recovery page, confirm the email address through the secure flow, and contact support if the reset link still fails. Never send your password in chat.",
        "response_b": "Post your password here so I can test the account for you.",
        "reference_intent": "Account Access",
        "reference_winner": "Response A",
        "predicted_spans": [
            ("sign in", "ISSUE"),
            ("CloudDesk", "PRODUCT"),
            ("Monday", "DATE"),
        ],
    },

What do these cases test?

These cases introduce a wrong ORDER_ID label and an incomplete account-access boundary. Their response pairs also test unsupported promises and requests for private credentials.

  • Add case T-004 below T-003 by pasting this chunk:
    {
        "ticket_id": "T-004",
        "ticket": "My AeroBuds order ORD-3301 is still missing after the promised date 22 October.",
        "response_a": "I can help trace ORD-3301. Check the carrier status in your account, then contact support if no new scan appears.",
        "response_b": "Let's trace ORD-3301. Review the carrier status in your account and contact support if tracking has not updated.",
        "reference_intent": "Delivery",
        "reference_winner": "Tie",
        "predicted_spans": [
            ("AeroBuds", "PRODUCT"),
            ("ORD-3301", "ORDER_ID"),
            ("missing", "ISSUE"),
            ("promised date", "DATE"),
            ("22 October", "DATE"),
        ],
    },

What does this case test?

This delivery case includes an incomplete ISSUE span plus an extra DATE candidate. Its closely matched responses provide a meaningful use case for the Tie decision.

  • Finish the CASES list with T-005 and T-006 by pasting this chunk:
    {
        "ticket_id": "T-005",
        "ticket": "The battery on my NovaTab overheats, and order ORD-4410 was delivered on 3 October.",
        "response_a": "Stop using and charging the NovaTab while it is overheating. Move it away from flammable materials and contact product support through the secure account page for the next safe step.",
        "response_b": "Put the device in a freezer while it is charging to cool it quickly.",
        "reference_intent": "Technical Support",
        "reference_winner": "Response A",
        "predicted_spans": [
            ("battery", "PRODUCT"),
            ("NovaTab", "PRODUCT"),
            ("overheats", "ISSUE"),
            ("ORD-4410", "ORDER_ID"),
            ("3 October", "DATE"),
        ],
    },
    {
        "ticket_id": "T-006",
        "ticket": "Cancel order ORD-5520 for PlanPlus before 30 October; the app keeps crashing.",
        "response_a": "I can help with the cancellation request for ORD-5520 and record the app crash. Confirm the order through the secure account page before any cancellation is processed.",
        "response_b": "It is already cancelled and the crash proves your account has been deleted.",
        "reference_intent": "Cancellation",
        "reference_winner": "Response A",
        "predicted_spans": [
            ("ORD-5520", "ORDER_ID"),
            ("PlanPlus", "ISSUE"),
            ("October", "DATE"),
            ("crashing", "ISSUE"),
        ],
    },
]

What do the final cases add?

The final cases cover unsafe troubleshooting advice and invented account claims. Their predictions include wrong labels plus incomplete DATE and ISSUE boundaries.

  • Add the offset-building helper below the completed CASES list by pasting this function:
def make_span(text, phrase, label, result_id):
    start = text.index(phrase)
    end = start + len(phrase)
    return {
        "id": result_id,
        "from_name": "entities",
        "to_name": "ticket",
        "type": "labels",
        "value": {
            "start": start,
            "end": end,
            "text": phrase,
            "labels": [label],
        },
    }

How are span offsets calculated?

  • The make_span() function finds each phrase inside the complete ticket text.
  • The start offset records the phrase's first character position.
  • The end offset adds the phrase length to the start position.
  • The returned structure matches the imported prediction shape used by Label Studio.
  • Add the task-building function below make_span() by pasting this chunk:
def build_task(case):
    results = [
        make_span(
            case["ticket"],
            phrase,
            label,
            f'{case["ticket_id"]}-{index}',
        )
        for index, (phrase, label) in enumerate(case["predicted_spans"], start=1)
    ]

    return {
        "data": {
            "ticket_id": case["ticket_id"],
            "ticket": case["ticket"],
            "response_a": case["response_a"],
            "response_b": case["response_b"],
            "reference_intent": case["reference_intent"],
            "reference_winner": case["reference_winner"],
        },
        "predictions": [
            {
                "model_version": "rules-v1",
                "result": results,
            }
        ],
    }

How does one case become a task?

  • The results list converts every predicted phrase into a region with calculated offsets.
  • The data object carries the ticket, candidate responses, and disclosed simulated references.
  • The predictions list records rules-v1 as the baseline source.
  • Finish generate_tasks.py with the file-writing entry point by pasting this chunk:
def main():
    tasks = [build_task(case) for case in CASES]
    Path("tasks.json").write_text(
        json.dumps(tasks, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )
    print(f"Wrote {len(tasks)} tasks to tasks.json")


if __name__ == "__main__":
    main()

What does the generator protect?

The main() function converts all six cases into tasks and saves them as JSON, a structured text format that Label Studio can import.

Python calculates every character offset and writes the nested structure consistently. Building the JSON by hand would make small counting and punctuation errors difficult to spot.

The final message reports the number of written tasks. That count gives you an immediate check that the generator completed.

  • Press Ctrl+O to write generate_tasks.py.
  • Press Enter to confirm the filename.
  • Press Ctrl+X to return to the terminal.

Before you run the generator, how many tasks do you expect it to write?

  • Generate tasks.json by running this command:
python generate_tasks.py

What does this command do?

Python runs the generator from the active workspace. The script calculates every predicted span offset before writing the six tasks to tasks.json.

You'll see Wrote 6 tasks to tasks.json. That's your first visible win: every synthetic ticket is now a repeatable benchmark task.

Generator not writing six tasks?

Check that every case sits inside the CASES list. A missing comma or closing bracket can stop Python before it writes the file.

Confirm that your second terminal is inside ~/nlp-evaluation-audit before running the generator.

Help me debug the task generator.

Create the combined evaluation interface

A custom interface lets one reviewer correct entities, classify intent, compare responses, and document the decision in the same task. Matching control names also make the later JSON export predictable.

  • Create label_config.xml with GNU nano by running this command:
nano label_config.xml

What does this command do?

GNU nano opens a new XML file for the custom Label Studio interface. The following chunks build its entity and evaluation controls.

  • Start label_config.xml with the page styling and entity controls by pasting this chunk:
<View className="root">
  <Style>
    .root { font-family: sans-serif; line-height: 1.5; }
    .ticket { background: #f7f7f7; border: 1px solid #d9d9d9; border-radius: 8px; padding: 14px; }
    .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
    .candidate { border: 1px solid #c8c8c8; border-radius: 8px; padding: 12px; }
  </Style>

  <Header value="Customer ticket" />
  <Labels name="entities" toName="ticket">
    <Label value="PRODUCT" background="#5B8FF9" />
    <Label value="ISSUE" background="#F6BD16" />
    <Label value="ORDER_ID" background="#5AD8A6" />
    <Label value="DATE" background="#E8684A" />
  </Labels>
  <View className="ticket">
    <Text name="ticket" value="$ticket" granularity="word" />
  </View>

How does the entity interface work?

  • The style rules separate the ticket and response candidates into visible cards.
  • The Labels control defines the four entity types used by the rubric.
  • Word granularity keeps selected spans aligned to complete words.
  • Add the intent control and side-by-side response cards beneath the ticket view by pasting this chunk:
  <Header value="Primary customer intent" />
  <Choices name="intent" toName="ticket" choice="single-radio" required="true" showInline="true">
    <Choice value="Refund" />
    <Choice value="Delivery" />
    <Choice value="Technical Support" />
    <Choice value="Account Access" />
    <Choice value="Cancellation" />
  </Choices>

  <Header value="Compare the support responses" />
  <View className="two-col">
    <View className="candidate">
      <Header value="Response A" />
      <Text name="response_a" value="$response_a" />
    </View>
    <View className="candidate">
      <Header value="Response B" />
      <Text name="response_b" value="$response_b" />
    </View>
  </View>

How are intent and responses presented?

The intent control requires one primary classification. The two-column layout keeps both support responses visible during preference evaluation.

  • Finish the interface with the winner, error category, and rationale controls by pasting this chunk:
  <Header value="Select the better response" />
  <Choices name="winner" toName="ticket" choice="single-radio" required="true" showInline="true">
    <Choice value="Response A" />
    <Choice value="Response B" />
    <Choice value="Tie" />
  </Choices>

  <Header value="Primary error type in the losing response" />
  <Choices name="error_type" toName="ticket" choice="single-radio" required="true" showInline="true">
    <Choice value="Factual Error" />
    <Choice value="Unsupported Promise" />
    <Choice value="Unsafe Advice" />
    <Choice value="Privacy Risk" />
    <Choice value="Poor Helpfulness" />
    <Choice value="No Material Difference" />
  </Choices>

  <Header value="Decision rationale" />
  <TextArea name="rationale" toName="ticket" rows="3" maxSubmissions="1" required="true" placeholder="State the decisive evidence from the ticket and responses." />
</View>

How is the evaluation recorded?

  • The winner control records Response A, Response B, or Tie.
  • The error_type control captures the losing response's primary flaw.
  • The required rationale field preserves the evidence behind the decision.
  • Press Ctrl+O to write label_config.xml.
  • Press Enter to confirm the filename.
  • Press Ctrl+X to return to the terminal.
  • Switch back to the Label Studio Projects page in Chrome.
  • Click Create Project.
  • Enter NLP and LLM Support Audit in the Project Name field.
  • Select Labeling Setup.
  • Choose the option for a custom configuration.

Why use one combined interface?

A single interface keeps span correction beside the structured evaluation decision. This makes each exported task a complete QA record instead of several disconnected annotations.

  • Replace the configuration editor contents with the complete contents of label_config.xml.
  • Click Save.

You should see the configuration accepted without an error. The project now has PRODUCT, ISSUE, ORDER_ID, DATE, intent, response comparison, winner, error category, and rationale controls.

Configuration not accepted?

Check that the outer View element closes at the bottom of the file. Confirm that every opening control tag has its matching closing tag.

Compare the control names with entities, intent, winner, error_type, and rationale.

Help me debug the Label Studio configuration.

Import and inspect the predicted tasks

Imported predictions provide a baseline for human correction. They remain linked to rules-v1 so the later audit can compare model suggestions with reviewed annotations.

  • Open the project's Data Manager.
  • Click Import.
  • Upload tasks.json from ~/nlp-evaluation-audit.

You should see six imported tasks. Their ticket IDs run from T-001 through T-006.

Before you open the first task, do you expect every predicted entity to follow the complete boundary rules?

  • Open the first task from the Data Manager.
  • Use the task view's available copy action to copy the imported prediction into a new annotation.
  • Compare the visible entity suggestions with annotation_guidelines.md.
  • Leave the copied prediction uncorrected for now.

You should see a customer ticket with colored entity suggestions, intent choices, two response cards, winner choices, error categories, and a rationale field. The suggested DATE covers only October, while the suggested ISSUE covers only screen.

Predictions or controls missing?

Confirm that the import contains six tasks from tasks.json and that the project uses the saved contents of label_config.xml.

If the spans are visible but read-only, use the task view's prediction-copy action to create an editable annotation.

Help me find the missing benchmark elements.

✔️ Awesome, I've got everything!

Your six-task benchmark is imported. Double-check that annotation_guidelines.md, generate_tasks.py, and label_config.xml are saved in ~/nlp-evaluation-audit.

ⓧ I'd like to double check the full code

Compare annotation_guidelines.md with this complete file:

# Annotation Guidelines

## Entity ontology

- PRODUCT: The complete marketed product or service name, such as `Pixel 9` or `SoundWave Pro`.
- ISSUE: The smallest complete phrase that preserves the reported failure. Include necessary negation or aspect words, such as `cannot sign in`, `stopped charging`, `still missing`, or `keeps crashing`.
- ORDER_ID: The full order identifier, including its prefix and hyphen.
- DATE: The complete temporal expression, such as `14 October`, `Monday`, or `yesterday`.

Do not include surrounding punctuation. Annotate every valid mention once. When a suggested boundary is incomplete, delete or resize it so the final span follows these rules.

## Intent ontology

- Refund: The customer explicitly asks for money back.
- Delivery: The main request concerns shipping, tracking, lateness, or a missing package.
- Technical Support: The main request concerns product malfunction or troubleshooting.
- Account Access: The customer cannot access an account or sign in.
- Cancellation: The customer asks to cancel an order, renewal, or service.

Choose the primary intent that best explains the action the support response should take.

## Response evaluation rubric

Evaluate both responses using the ticket only. Prefer the response that is more accurate, helpful, safe, privacy-preserving, and appropriately cautious about promises.

Winner choices:

- Response A: A is materially better.
- Response B: B is materially better.
- Tie: Neither response has a meaningful advantage.

Primary error categories:

- Factual Error
- Unsupported Promise
- Unsafe Advice
- Privacy Risk
- Poor Helpfulness
- No Material Difference

Write one or two sentences that quote or paraphrase the decisive evidence. Do not reward confident wording when the response invents policy, timing, account status, or facts that are not present in the ticket.

Compare generate_tasks.py with this complete file:

import json
from pathlib import Path


CASES = [
    {
        "ticket_id": "T-001",
        "ticket": "My Pixel 9 order ORD-1042 arrived on 14 October with a cracked screen.",
        "response_a": "Sorry about the damaged Pixel 9. I can help with replacement options for order ORD-1042. Please confirm the delivery country through the secure account page, but do not share payment details here.",
        "response_b": "Screens sometimes crack. Buy another phone and send your card number so I can process it.",
        "reference_intent": "Technical Support",
        "reference_winner": "Response A",
        "predicted_spans": [
            ("Pixel 9", "PRODUCT"),
            ("ORD-1042", "ORDER_ID"),
            ("October", "DATE"),
            ("screen", "ISSUE"),
        ],
    },
    {
        "ticket_id": "T-002",
        "ticket": "Please refund my SoundWave Pro order ORD-2088; it stopped charging yesterday.",
        "response_a": "Your refund is approved and the money will arrive in exactly one hour.",
        "response_b": "I can help start a refund request for ORD-2088. Verify the purchase in your secure account; eligibility and timing depend on the applicable return policy.",
        "reference_intent": "Refund",
        "reference_winner": "Response B",
        "predicted_spans": [
            ("SoundWave Pro", "PRODUCT"),
            ("ORD-2088", "PRODUCT"),
            ("yesterday", "DATE"),
        ],
    },
    {
        "ticket_id": "T-003",
        "ticket": "I cannot sign in to CloudDesk after resetting my password on Monday.",
        "response_a": "Use the account recovery page, confirm the email address through the secure flow, and contact support if the reset link still fails. Never send your password in chat.",
        "response_b": "Post your password here so I can test the account for you.",
        "reference_intent": "Account Access",
        "reference_winner": "Response A",
        "predicted_spans": [
            ("sign in", "ISSUE"),
            ("CloudDesk", "PRODUCT"),
            ("Monday", "DATE"),
        ],
    },
    {
        "ticket_id": "T-004",
        "ticket": "My AeroBuds order ORD-3301 is still missing after the promised date 22 October.",
        "response_a": "I can help trace ORD-3301. Check the carrier status in your account, then contact support if no new scan appears.",
        "response_b": "Let's trace ORD-3301. Review the carrier status in your account and contact support if tracking has not updated.",
        "reference_intent": "Delivery",
        "reference_winner": "Tie",
        "predicted_spans": [
            ("AeroBuds", "PRODUCT"),
            ("ORD-3301", "ORDER_ID"),
            ("missing", "ISSUE"),
            ("promised date", "DATE"),
            ("22 October", "DATE"),
        ],
    },
    {
        "ticket_id": "T-005",
        "ticket": "The battery on my NovaTab overheats, and order ORD-4410 was delivered on 3 October.",
        "response_a": "Stop using and charging the NovaTab while it is overheating. Move it away from flammable materials and contact product support through the secure account page for the next safe step.",
        "response_b": "Put the device in a freezer while it is charging to cool it quickly.",
        "reference_intent": "Technical Support",
        "reference_winner": "Response A",
        "predicted_spans": [
            ("battery", "PRODUCT"),
            ("NovaTab", "PRODUCT"),
            ("overheats", "ISSUE"),
            ("ORD-4410", "ORDER_ID"),
            ("3 October", "DATE"),
        ],
    },
    {
        "ticket_id": "T-006",
        "ticket": "Cancel order ORD-5520 for PlanPlus before 30 October; the app keeps crashing.",
        "response_a": "I can help with the cancellation request for ORD-5520 and record the app crash. Confirm the order through the secure account page before any cancellation is processed.",
        "response_b": "It is already cancelled and the crash proves your account has been deleted.",
        "reference_intent": "Cancellation",
        "reference_winner": "Response A",
        "predicted_spans": [
            ("ORD-5520", "ORDER_ID"),
            ("PlanPlus", "ISSUE"),
            ("October", "DATE"),
            ("crashing", "ISSUE"),
        ],
    },
]


def make_span(text, phrase, label, result_id):
    start = text.index(phrase)
    end = start + len(phrase)
    return {
        "id": result_id,
        "from_name": "entities",
        "to_name": "ticket",
        "type": "labels",
        "value": {
            "start": start,
            "end": end,
            "text": phrase,
            "labels": [label],
        },
    }


def build_task(case):
    results = [
        make_span(
            case["ticket"],
            phrase,
            label,
            f'{case["ticket_id"]}-{index}',
        )
        for index, (phrase, label) in enumerate(case["predicted_spans"], start=1)
    ]

    return {
        "data": {
            "ticket_id": case["ticket_id"],
            "ticket": case["ticket"],
            "response_a": case["response_a"],
            "response_b": case["response_b"],
            "reference_intent": case["reference_intent"],
            "reference_winner": case["reference_winner"],
        },
        "predictions": [
            {
                "model_version": "rules-v1",
                "result": results,
            }
        ],
    }


def main():
    tasks = [build_task(case) for case in CASES]
    Path("tasks.json").write_text(
        json.dumps(tasks, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )
    print(f"Wrote {len(tasks)} tasks to tasks.json")


if __name__ == "__main__":
    main()

Compare label_config.xml with this complete file:

<View className="root">
  <Style>
    .root { font-family: sans-serif; line-height: 1.5; }
    .ticket { background: #f7f7f7; border: 1px solid #d9d9d9; border-radius: 8px; padding: 14px; }
    .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
    .candidate { border: 1px solid #c8c8c8; border-radius: 8px; padding: 12px; }
  </Style>

  <Header value="Customer ticket" />
  <Labels name="entities" toName="ticket">
    <Label value="PRODUCT" background="#5B8FF9" />
    <Label value="ISSUE" background="#F6BD16" />
    <Label value="ORDER_ID" background="#5AD8A6" />
    <Label value="DATE" background="#E8684A" />
  </Labels>
  <View className="ticket">
    <Text name="ticket" value="$ticket" granularity="word" />
  </View>

  <Header value="Primary customer intent" />
  <Choices name="intent" toName="ticket" choice="single-radio" required="true" showInline="true">
    <Choice value="Refund" />
    <Choice value="Delivery" />
    <Choice value="Technical Support" />
    <Choice value="Account Access" />
    <Choice value="Cancellation" />
  </Choices>

  <Header value="Compare the support responses" />
  <View className="two-col">
    <View className="candidate">
      <Header value="Response A" />
      <Text name="response_a" value="$response_a" />
    </View>
    <View className="candidate">
      <Header value="Response B" />
      <Text name="response_b" value="$response_b" />
    </View>
  </View>

  <Header value="Select the better response" />
  <Choices name="winner" toName="ticket" choice="single-radio" required="true" showInline="true">
    <Choice value="Response A" />
    <Choice value="Response B" />
    <Choice value="Tie" />
  </Choices>

  <Header value="Primary error type in the losing response" />
  <Choices name="error_type" toName="ticket" choice="single-radio" required="true" showInline="true">
    <Choice value="Factual Error" />
    <Choice value="Unsupported Promise" />
    <Choice value="Unsafe Advice" />
    <Choice value="Privacy Risk" />
    <Choice value="Poor Helpfulness" />
    <Choice value="No Material Difference" />
  </Choices>

  <Header value="Decision rationale" />
  <TextArea name="rationale" toName="ticket" rows="3" maxSubmissions="1" required="true" placeholder="State the decisive evidence from the ticket and responses." />
</View>

Your benchmark now combines synthetic tickets, baseline predictions, and a documented evaluation interface. Next, you will adjudicate all six tasks and discover what strict metrics leave unexplained.

Adjudicate the Baseline

Your six-ticket benchmark is ready in Label Studio. The model suggestions still need a human decision before they can become trustworthy evaluation evidence.

This review process is called adjudication. You check each model suggestion against the shared rules and correct anything that falls short.

In this step, get ready to:
  • Adjudicate all six support tickets against the annotation rubric.
  • Export the completed human annotations as annotations.json.
  • Run a strict entity scorer to measure the baseline.
Review all six tasks against the rubric

A copied prediction gives you an editable starting point while preserving the original model output. Your submitted annotation becomes the human-reviewed comparison target.

  • Switch back to the first task in the NLP and LLM Support Audit project.
  • Keep annotation_guidelines.md available as your decision rubric.
  • Copy the imported prediction into a new annotation.
  • Resize the incomplete DATE suggestion from October to 14 October.
  • Resize the incomplete ISSUE suggestion from screen to cracked screen.
  • Select the primary customer intent using the ticket request.
  • Select the stronger response using the response evaluation rubric.
  • Select the losing response's main error category.

What makes this a gold annotation?

A gold annotation is an answer that a person has checked against the rubric. It becomes the comparison target for the model's original suggestion.

The model selects October, while the rule requires 14 October. Your saved review keeps the complete date without erasing the original guess.

Repeated differences reveal where the model or the guidelines need work. Clear rules make those differences meaningful.

Adjudicate without bias

  • Read the ticket and written rubric before deciding whether the prediction is correct.
  • Preserve the imported prediction separately from the submitted gold annotation.
  • Base each rationale on decisive evidence from the ticket or candidate responses.
  • Record ambiguous cases for a rubric update instead of inventing a new rule during review.
  • Enter a concise rationale that cites decisive evidence from the ticket or responses.
  • Submit the completed annotation for T-001 using the task submission control.
  • Adjudicate T-002 with the same rubric.
  • Adjudicate T-003 with the same rubric.
  • Adjudicate T-004 with the same rubric.
  • Adjudicate T-005 with the same rubric.
  • Adjudicate T-006 with the same rubric.
  • Return to the Data Manager to confirm that all six tasks have submitted annotations.

That's the review complete: all six tickets now have corrected spans and structured evaluation decisions.

Export the reviewed annotations

The raw JSON export keeps each ticket, model prediction, and human annotation together. A flat table would lose the nested spans, choices, and rationales that make the audit reproducible.

  • Return to the project's Data Manager.
  • Confirm that the annotated task count is 6.
  • Click Export.
  • Select JSON as the export format.
  • Download the export file.
  • Move the downloaded file into ~/nlp-evaluation-audit using your Linux file manager.
  • Rename the exported file to annotations.json.
  • Confirm that annotations.json appears beside tasks.json.

Why preserve the predictions?

The audit reads model spans from predictions. It reads human-reviewed spans from the latest item in annotations.

Removing the imported predictions would erase the baseline you want to measure.

Build and run the strict scorer

Think of every character in a ticket as having a numbered seat. The start and end numbers tell the scorer exactly which text the model selected, so even a one-character difference counts as a mismatch.

  • Create audit.py inside ~/nlp-evaluation-audit with your Linux text editor.
  • Paste the complete file from the double-check tab below into audit.py.

✔️ Awesome, I've got everything!

Confirm that audit.py is saved beside annotations.json in ~/nlp-evaluation-audit.

ⓧ I'd like to double check the full code

Compare your audit.py file with this complete version:

import json
from pathlib import Path


EXPORT_PATH = Path("annotations.json")
REPORT_PATH = Path("audit_report.md")
ERROR_KEYS = [
    "correct",
    "wrong_label",
    "boundary_error",
    "boundary_and_label",
    "false_positive",
    "missed_entity",
]


def load_tasks(path):
    return json.loads(path.read_text(encoding="utf-8"))


def latest_results(task):
    annotations = task.get("annotations", [])
    if not annotations:
        return []
    return annotations[-1].get("result", [])


def prediction_results(task):
    predictions = task.get("predictions", [])
    if not predictions:
        return []
    return predictions[0].get("result", [])


def extract_spans(results):
    spans = []
    for result in results:
        if result.get("from_name") != "entities" or result.get("type") != "labels":
            continue
        value = result.get("value", {})
        labels = value.get("labels", [])
        if not labels:
            continue
        spans.append(
            {
                "start": value.get("start"),
                "end": value.get("end"),
                "label": labels[0],
                "text": value.get("text", ""),
            }
        )
    return spans


def extract_choice(results, control_name):
    for result in results:
        if result.get("from_name") == control_name and result.get("type") == "choices":
            choices = result.get("value", {}).get("choices", [])
            return choices[0] if choices else None
    return None


def extract_text(results, control_name):
    for result in results:
        if result.get("from_name") == control_name and result.get("type") == "textarea":
            values = result.get("value", {}).get("text", [])
            return values[0].strip() if values else ""
    return ""


def entity_key(ticket_id, span):
    return (
        ticket_id,
        span["start"],
        span["end"],
        span["label"],
    )


def strict_metrics(tasks):
    predicted = set()
    gold = set()

    for task in tasks:
        data = task.get("data", {})
        ticket_id = data.get("ticket_id", str(task.get("id", "unknown")))
        predicted.update(
            entity_key(ticket_id, span)
            for span in extract_spans(prediction_results(task))
        )
        gold.update(
            entity_key(ticket_id, span)
            for span in extract_spans(latest_results(task))
        )

    true_positive = len(predicted & gold)
    precision = true_positive / len(predicted) if predicted else 0.0
    recall = true_positive / len(gold) if gold else 0.0
    f1 = (
        2 * precision * recall / (precision + recall)
        if precision + recall
        else 0.0
    )

    return {
        "true_positive": true_positive,
        "predicted": len(predicted),
        "gold": len(gold),
        "precision": precision,
        "recall": recall,
        "f1": f1,
    }


def overlaps(left, right):
    return max(left["start"], right["start"]) < min(left["end"], right["end"])


def match_pass(predicted, gold, predicate):
    used_predicted = set()
    used_gold = set()
    matches = 0

    for predicted_index, predicted_span in enumerate(predicted):
        for gold_index, gold_span in enumerate(gold):
            if gold_index in used_gold:
                continue
            if predicate(predicted_span, gold_span):
                used_predicted.add(predicted_index)
                used_gold.add(gold_index)
                matches += 1
                break

    remaining_predicted = [
        span
        for index, span in enumerate(predicted)
        if index not in used_predicted
    ]
    remaining_gold = [
        span
        for index, span in enumerate(gold)
        if index not in used_gold
    ]
    return matches, remaining_predicted, remaining_gold


def diagnose_task(predicted, gold):
    counts = {key: 0 for key in ERROR_KEYS}

    counts["correct"], predicted, gold = match_pass(
        predicted,
        gold,
        lambda left, right: (
            left["start"] == right["start"]
            and left["end"] == right["end"]
            and left["label"] == right["label"]
        ),
    )

    counts["wrong_label"], predicted, gold = match_pass(
        predicted,
        gold,
        lambda left, right: (
            left["start"] == right["start"]
            and left["end"] == right["end"]
            and left["label"] != right["label"]
        ),
    )

    counts["boundary_error"], predicted, gold = match_pass(
        predicted,
        gold,
        lambda left, right: (
            left["label"] == right["label"] and overlaps(left, right)
        ),
    )

    counts["boundary_and_label"], predicted, gold = match_pass(
        predicted,
        gold,
        lambda left, right: overlaps(left, right),
    )

    counts["false_positive"] = len(predicted)
    counts["missed_entity"] = len(gold)
    return counts


def diagnostic_totals(tasks):
    totals = {key: 0 for key in ERROR_KEYS}
    for task in tasks:
        task_counts = diagnose_task(
            extract_spans(prediction_results(task)),
            extract_spans(latest_results(task)),
        )
        for key in ERROR_KEYS:
            totals[key] += task_counts[key]
    return totals


def reference_agreement(tasks, control_name, reference_key):
    matched = 0
    reviewed = 0
    for task in tasks:
        selected = extract_choice(latest_results(task), control_name)
        reference = task.get("data", {}).get(reference_key)
        if selected is None or reference is None:
            continue
        reviewed += 1
        matched += int(selected == reference)
    return matched, reviewed


def choice_distribution(tasks, control_name):
    distribution = {}
    for task in tasks:
        selected = extract_choice(latest_results(task), control_name)
        if selected is not None:
            distribution[selected] = distribution.get(selected, 0) + 1
    return distribution


def rationale_coverage(tasks):
    completed = 0
    with_rationale = 0
    for task in tasks:
        results = latest_results(task)
        if not results:
            continue
        completed += 1
        with_rationale += int(bool(extract_text(results, "rationale")))
    return with_rationale, completed


def ratio_text(numerator, denominator):
    if denominator == 0:
        return "0/0 (not available)"
    percentage = numerator / denominator * 100
    return f"{numerator}/{denominator} ({percentage:.1f}%)"


def build_report(tasks, metrics, errors):
    intent_match, intent_total = reference_agreement(
        tasks,
        "intent",
        "reference_intent",
    )
    winner_match, winner_total = reference_agreement(
        tasks,
        "winner",
        "reference_winner",
    )
    rationale_match, rationale_total = rationale_coverage(tasks)
    error_distribution = choice_distribution(tasks, "error_type")
    reviewed_tasks = sum(bool(latest_results(task)) for task in tasks)

    diagnostic_lines = "\n".join(
        f'- {key.replace("_", " ").title()}: {errors[key]}'
        for key in ERROR_KEYS
    )
    response_error_lines = "\n".join(
        f"- {label}: {count}"
        for label, count in sorted(error_distribution.items())
    ) or "- No completed response error classifications"

    return f"""# NLP and LLM Evaluation Audit

## Dataset

- Imported tasks: {len(tasks)}
- Reviewed tasks: {reviewed_tasks}
- Baseline prediction source: rules-v1
- Human gold source: latest submitted annotation per task

## Strict NER Metrics

- Exact true positives: {metrics['true_positive']}
- Predicted entities: {metrics['predicted']}
- Human gold entities: {metrics['gold']}
- Precision: {metrics['precision']:.3f}
- Recall: {metrics['recall']:.3f}
- F1: {metrics['f1']:.3f}

Strict matching requires ticket ID, start offset, end offset, and entity label to match.

## NER Error Diagnosis

{diagnostic_lines}

## Structured Evaluation QA

- Intent agreement with disclosed reference: {ratio_text(intent_match, intent_total)}
- Winner agreement with disclosed reference: {ratio_text(winner_match, winner_total)}
- Completed rationales: {ratio_text(rationale_match, rationale_total)}

## Response Error Distribution

{response_error_lines}

## Recommended Actions

- Model-side action: Add training examples for the most frequent NER error category and preserve complete symptom and date boundaries.
- Process-side action: Keep the ISSUE boundary rule and evidence-based response rubric visible during annotation and adjudication.
- Validation action: Re-run this same audit after changing the pre-annotation rules or reviewer guidelines.
"""


def main():
    tasks = load_tasks(EXPORT_PATH)
    metrics = strict_metrics(tasks)

    print("Strict NER metrics")
    print(f"Precision: {metrics['precision']:.3f}")
    print(f"Recall:    {metrics['recall']:.3f}")
    print(f"F1:        {metrics['f1']:.3f}")
    print("Limitation: strict scores reveal mismatches but do not explain their causes.")

    errors = diagnostic_totals(tasks)
    print("\nDiagnostic counts")
    for key in ERROR_KEYS:
        print(f"{key}: {errors[key]}")

    report = build_report(tasks, metrics, errors)
    REPORT_PATH.write_text(report, encoding="utf-8")
    print(f"\nWrote {REPORT_PATH}")


if __name__ == "__main__":
    main()

What does this file do?

  • The loader helpers read annotations.json.
  • The extraction helpers separate model spans from the latest human results.
  • The entity_key() function turns each entity into an exact comparison key.
  • The audit helpers keep metrics plus diagnostic evidence in one reproducible script.
  • Save audit.py.

Before you run the audit, do you think a one-character boundary difference will count as partly correct or fully wrong?

  • Run the strict audit from ~/nlp-evaluation-audit with this command:
python audit.py

What do the strict scores measure?

Imagine sorting fruit into an apple basket. Precision asks how many items in the basket really are apples, just as model precision asks how many predictions are correct.

Recall starts with every apple in the room. It asks how many made it into the basket, just as model recall asks how many correct answers the model found.

A model can raise precision by making fewer, safer guesses. It can raise recall by making more guesses, but that may add wrong ones.

F1 gives one score that balances precision and recall. It is useful when both wrong guesses and missed answers matter.

You'll see Strict NER metrics followed by precision, recall, and F1 values. The limitation line confirms that those scores reveal mismatches without explaining their causes.

That is the baseline exposed. You have turned six human reviews into measurable QA evidence.

Audit not reaching the metrics?

Confirm that annotations.json sits in the same folder as audit.py.

Confirm that you exported the raw project data after submitting all six annotations. The audit needs both prediction results and annotation results.

Compare your file with the complete reference if Python reports an indentation or syntax problem.

Ask for help with the exported file or audit output.

Your reviewed baseline now has a reproducible score. Next, you'll separate that score into specific NLP failure modes.

Diagnose Evaluation Failure Modes

Your strict NER scorer already reveals whether each predicted entity matches the reviewed gold span exactly. The score still leaves the cause of each mismatch hidden.

An F1 score is like a warning light on a car. It tells you something is wrong, while an error taxonomy sorts the mistakes into clear groups so you can see what needs fixing.

You will run controlled failures through the matcher before auditing intent classification, response preferences, error categories, and rationale coverage. The same script then generates a Markdown report with the leading finding and recommended actions.

In this step, get ready to:
  • Run controlled examples through the real entity matcher.
  • Audit the structured intent, winner, error, and rationale fields.
  • Generate a report with findings and recommendations.
Test the diagnostic matcher

Strict matching treats October instead of 14 October as an error. The diagnostic passes separate that boundary problem from a wrong label, a missed entity, or an extra prediction, so each category points to a different fix.

  • Press the Super key to open GNOME Activities.
  • Type Text Editor in the search field.
  • Open Text Editor from the results.
  • Create diagnostic_checks.py inside ~/nlp-evaluation-audit.
  • Build four controlled matcher tests by pasting the code below:
from audit import diagnose_task


def span(start, end, label):
    # Build the minimal entity shape used by the diagnostic matcher.
    return {"start": start, "end": end, "label": label, "text": ""}


# Each case isolates one matching rule so its category is observable.
cases = [
    ("exact match", [span(0, 7, "PRODUCT")], [span(0, 7, "PRODUCT")], "correct"),
    ("wrong label", [span(0, 7, "ISSUE")], [span(0, 7, "PRODUCT")], "wrong_label"),
    ("boundary error", [span(3, 10, "DATE")], [span(0, 10, "DATE")], "boundary_error"),
    ("boundary plus label", [span(3, 10, "ISSUE")], [span(0, 10, "DATE")], "boundary_and_label"),
]

for name, predicted, gold, expected in cases:
    # Run the real matcher and fail immediately if a rule changes.
    counts = diagnose_task(predicted, gold)
    assert counts[expected] == 1, counts
    print(f"PASS: {name} -> {expected}")

What do these checks prove?

  • span() creates one controlled model or gold entity without loading the full export.
  • Each case changes one property: the label, the boundary, or both.
  • The assertion turns the taxonomy into an executable rule. A changed result stops the check instead of silently altering the report.
  • Save diagnostic_checks.py.
  • Return to the terminal inside ~/nlp-evaluation-audit.
  • Before you run the checks, which case do you expect to require both boundary and label changes?
python diagnostic_checks.py

You'll see four PASS lines. The last line maps the combined defect to boundary_and_label, which proves the matcher executes the taxonomy you just reviewed.

Checks not passing?

Confirm that diagnostic_checks.py and audit.py are in the same folder. Python imports diagnose_task() from the audit file.

If an assertion prints a counts dictionary, compare that case's offsets and labels with the reference code above.

Help me debug the diagnostic checks.

How does ordered diagnosis work?

Imagine matching people with their bags at an airport desk. Staff handle the clearest matches first and remove each claimed bag, so the same bag cannot be matched twice.

  • First, exact text with the correct label becomes correct.
  • Next, exact text with the wrong label becomes wrong_label.
  • Then, overlapping text with the correct label becomes boundary_error.
  • Other overlapping text becomes boundary_and_label.
  • A model guess with no match becomes false_positive.
  • A reviewed answer with no match becomes missed_entity.

This order puts each entity into one group only. You can then see which kind of mistake happens most often.

Diagnose before retraining

  • Apply mutually exclusive error categories in the same order for every task.
  • Report each count with the number of reviewed tasks or entities.
  • Inspect examples behind the largest category before recommending a model change.
  • Separate model failures from unclear guidelines or inconsistent human review.
  • Treat findings from six synthetic tickets as audit evidence, not a production performance claim.
Audit the structured evaluation decisions

Entity spans cover only one layer of the benchmark. The audit also checks whether the latest human annotation agrees with the disclosed simulated references.

  • In audit.py, locate reference_agreement().
  • Trace the intent control into reference_intent inside build_report().
  • Trace the winner control into reference_winner inside build_report().
  • Locate the error_type call to choice_distribution().
  • Locate rationale_coverage() and confirm that it checks for non-empty rationale text.

What does reference agreement mean here?

The simulated references provide a disclosed answer key for this exercise. Agreement measures consistency with that key.

The result does not represent production ground truth or inter-annotator agreement. That distinction keeps the audit claim accurate.

Generate the audit report

The report combines model performance with evidence about the human evaluation record. Its recommendations connect the observed errors to model-side and process-side action.

Before you run the audit, predict whether the report separates strict model performance from reviewer-record completeness.

  • Return to the audit terminal from the previous step.
  • Run the completed audit from ~/nlp-evaluation-audit by using this command:
python audit.py

You should see strict precision, recall, and F1 followed by diagnostic counts. The final line should confirm Wrote audit_report.md.

  • Confirm that the terminal lists all six diagnostic categories.
  • Open audit_report.md from ~/nlp-evaluation-audit in your Markdown viewer.
  • Confirm that the report contains the Dataset and Strict NER Metrics sections.
  • Confirm that the report contains the NER Error Diagnosis and Structured Evaluation QA sections.
  • Confirm that the report contains the Response Error Distribution and Recommended Actions sections.

Report missing or incomplete?

Check that annotations.json is inside ~/nlp-evaluation-audit with audit.py.

Confirm that the export still contains the imported predictions and latest completed annotations.

Ask for help with the exact terminal output if the script stops before writing the report. Help me diagnose my audit report error.

That closes the biggest gap in the baseline. Your audit now names the failure modes behind the strict score and measures the completeness of the evaluation record.

The strict metrics measure exact entity agreement. The remaining report sections explain why predictions failed and how consistently the structured review was completed.

✔️ Awesome, I've got everything!

Great. Confirm that audit.py is saved and audit_report.md opens with populated audit sections.

ⓧ I'd like to double check the full code

Compare your audit.py file with the complete diagnostic audit below.

import json
from pathlib import Path


EXPORT_PATH = Path("annotations.json")
REPORT_PATH = Path("audit_report.md")
ERROR_KEYS = [
    "correct",
    "wrong_label",
    "boundary_error",
    "boundary_and_label",
    "false_positive",
    "missed_entity",
]


def load_tasks(path):
    return json.loads(path.read_text(encoding="utf-8"))


def latest_results(task):
    annotations = task.get("annotations", [])
    if not annotations:
        return []
    return annotations[-1].get("result", [])


def prediction_results(task):
    predictions = task.get("predictions", [])
    if not predictions:
        return []
    return predictions[0].get("result", [])


def extract_spans(results):
    spans = []
    for result in results:
        if result.get("from_name") != "entities" or result.get("type") != "labels":
            continue
        value = result.get("value", {})
        labels = value.get("labels", [])
        if not labels:
            continue
        spans.append(
            {
                "start": value.get("start"),
                "end": value.get("end"),
                "label": labels[0],
                "text": value.get("text", ""),
            }
        )
    return spans


def extract_choice(results, control_name):
    for result in results:
        if result.get("from_name") == control_name and result.get("type") == "choices":
            choices = result.get("value", {}).get("choices", [])
            return choices[0] if choices else None
    return None


def extract_text(results, control_name):
    for result in results:
        if result.get("from_name") == control_name and result.get("type") == "textarea":
            values = result.get("value", {}).get("text", [])
            return values[0].strip() if values else ""
    return ""


def entity_key(ticket_id, span):
    return (
        ticket_id,
        span["start"],
        span["end"],
        span["label"],
    )


def strict_metrics(tasks):
    predicted = set()
    gold = set()

    for task in tasks:
        data = task.get("data", {})
        ticket_id = data.get("ticket_id", str(task.get("id", "unknown")))
        predicted.update(
            entity_key(ticket_id, span)
            for span in extract_spans(prediction_results(task))
        )
        gold.update(
            entity_key(ticket_id, span)
            for span in extract_spans(latest_results(task))
        )

    true_positive = len(predicted & gold)
    precision = true_positive / len(predicted) if predicted else 0.0
    recall = true_positive / len(gold) if gold else 0.0
    f1 = (
        2 * precision * recall / (precision + recall)
        if precision + recall
        else 0.0
    )

    return {
        "true_positive": true_positive,
        "predicted": len(predicted),
        "gold": len(gold),
        "precision": precision,
        "recall": recall,
        "f1": f1,
    }


def overlaps(left, right):
    return max(left["start"], right["start"]) < min(left["end"], right["end"])


def match_pass(predicted, gold, predicate):
    used_predicted = set()
    used_gold = set()
    matches = 0

    for predicted_index, predicted_span in enumerate(predicted):
        for gold_index, gold_span in enumerate(gold):
            if gold_index in used_gold:
                continue
            if predicate(predicted_span, gold_span):
                used_predicted.add(predicted_index)
                used_gold.add(gold_index)
                matches += 1
                break

    remaining_predicted = [
        span
        for index, span in enumerate(predicted)
        if index not in used_predicted
    ]
    remaining_gold = [
        span
        for index, span in enumerate(gold)
        if index not in used_gold
    ]
    return matches, remaining_predicted, remaining_gold


def diagnose_task(predicted, gold):
    counts = {key: 0 for key in ERROR_KEYS}

    counts["correct"], predicted, gold = match_pass(
        predicted,
        gold,
        lambda left, right: (
            left["start"] == right["start"]
            and left["end"] == right["end"]
            and left["label"] == right["label"]
        ),
    )

    counts["wrong_label"], predicted, gold = match_pass(
        predicted,
        gold,
        lambda left, right: (
            left["start"] == right["start"]
            and left["end"] == right["end"]
            and left["label"] != right["label"]
        ),
    )

    counts["boundary_error"], predicted, gold = match_pass(
        predicted,
        gold,
        lambda left, right: (
            left["label"] == right["label"] and overlaps(left, right)
        ),
    )

    counts["boundary_and_label"], predicted, gold = match_pass(
        predicted,
        gold,
        lambda left, right: overlaps(left, right),
    )

    counts["false_positive"] = len(predicted)
    counts["missed_entity"] = len(gold)
    return counts


def diagnostic_totals(tasks):
    totals = {key: 0 for key in ERROR_KEYS}
    for task in tasks:
        task_counts = diagnose_task(
            extract_spans(prediction_results(task)),
            extract_spans(latest_results(task)),
        )
        for key in ERROR_KEYS:
            totals[key] += task_counts[key]
    return totals


def reference_agreement(tasks, control_name, reference_key):
    matched = 0
    reviewed = 0
    for task in tasks:
        selected = extract_choice(latest_results(task), control_name)
        reference = task.get("data", {}).get(reference_key)
        if selected is None or reference is None:
            continue
        reviewed += 1
        matched += int(selected == reference)
    return matched, reviewed


def choice_distribution(tasks, control_name):
    distribution = {}
    for task in tasks:
        selected = extract_choice(latest_results(task), control_name)
        if selected is not None:
            distribution[selected] = distribution.get(selected, 0) + 1
    return distribution


def rationale_coverage(tasks):
    completed = 0
    with_rationale = 0
    for task in tasks:
        results = latest_results(task)
        if not results:
            continue
        completed += 1
        with_rationale += int(bool(extract_text(results, "rationale")))
    return with_rationale, completed


def ratio_text(numerator, denominator):
    if denominator == 0:
        return "0/0 (not available)"
    percentage = numerator / denominator * 100
    return f"{numerator}/{denominator} ({percentage:.1f}%)"


def build_report(tasks, metrics, errors):
    intent_match, intent_total = reference_agreement(
        tasks,
        "intent",
        "reference_intent",
    )
    winner_match, winner_total = reference_agreement(
        tasks,
        "winner",
        "reference_winner",
    )
    rationale_match, rationale_total = rationale_coverage(tasks)
    error_distribution = choice_distribution(tasks, "error_type")
    reviewed_tasks = sum(bool(latest_results(task)) for task in tasks)

    diagnostic_lines = "\n".join(
        f'- {key.replace("_", " ").title()}: {errors[key]}'
        for key in ERROR_KEYS
    )
    response_error_lines = "\n".join(
        f"- {label}: {count}"
        for label, count in sorted(error_distribution.items())
    ) or "- No completed response error classifications"

    return f"""# NLP and LLM Evaluation Audit

## Dataset

- Imported tasks: {len(tasks)}
- Reviewed tasks: {reviewed_tasks}
- Baseline prediction source: rules-v1
- Human gold source: latest submitted annotation per task

## Strict NER Metrics

- Exact true positives: {metrics['true_positive']}
- Predicted entities: {metrics['predicted']}
- Human gold entities: {metrics['gold']}
- Precision: {metrics['precision']:.3f}
- Recall: {metrics['recall']:.3f}
- F1: {metrics['f1']:.3f}

Strict matching requires ticket ID, start offset, end offset, and entity label to match.

## NER Error Diagnosis

{diagnostic_lines}

## Structured Evaluation QA

- Intent agreement with disclosed reference: {ratio_text(intent_match, intent_total)}
- Winner agreement with disclosed reference: {ratio_text(winner_match, winner_total)}
- Completed rationales: {ratio_text(rationale_match, rationale_total)}

## Response Error Distribution

{response_error_lines}

## Recommended Actions

- Model-side action: Add training examples for the most frequent NER error category and preserve complete symptom and date boundaries.
- Process-side action: Keep the ISSUE boundary rule and evidence-based response rubric visible during annotation and adjudication.
- Validation action: Re-run this same audit after changing the pre-annotation rules or reviewer guidelines.
"""


def main():
    tasks = load_tasks(EXPORT_PATH)
    metrics = strict_metrics(tasks)

    print("Strict NER metrics")
    print(f"Precision: {metrics['precision']:.3f}")
    print(f"Recall:    {metrics['recall']:.3f}")
    print(f"F1:        {metrics['f1']:.3f}")
    print("Limitation: strict scores reveal mismatches but do not explain their causes.")

    errors = diagnostic_totals(tasks)
    print("\nDiagnostic counts")
    for key in ERROR_KEYS:
        print(f"{key}: {errors[key]}")

    report = build_report(tasks, metrics, errors)
    REPORT_PATH.write_text(report, encoding="utf-8")
    print(f"\nWrote {REPORT_PATH}")


if __name__ == "__main__":
    main()

Your report now connects each score to a concrete QA finding. Next, you'll preserve the predictions, reviewed decisions, scripts, report, and screenshots as one reproducible audit package.

Package the Portfolio Evidence

Package the Audit Evidence

Your diagnostic audit now turns the reviewed benchmark into measured failure categories. The final goal is to preserve enough evidence for another reviewer to reproduce that result.

You will keep the generated report unchanged, capture the model and human views, and assemble the source files beside them. The package should explain itself without relying on manual report edits.

In this step, get ready to:
  • Regenerate and inspect the final audit report.
  • Capture the baseline and reviewed task views with GNOME.
  • Assemble and verify the reproducibility package.
Regenerate the final report

The report is an output of audit.py. Keeping it generated prevents the written findings from drifting away from annotations.json.

  • Return to the terminal inside ~/nlp-evaluation-audit.
  • Before you rerun the audit, which diagnostic category do you expect to have the largest failure count?
  • Regenerate the metrics, findings, and recommendations by running:
python audit.py

What does the final run produce?

  • The strict section measures exact span agreement.
  • The diagnostic section separates boundary, label, extra-prediction, and missed-entity failures.
  • The structured QA section measures the intent, winner, and rationale records.
  • The recommended actions come from the reproducible audit script, so a later rerun rebuilds the same report structure.
  • Confirm that the terminal ends with Wrote audit_report.md.
  • Open audit_report.md in GNOME Text Editor.
  • Confirm that the report includes strict metrics, diagnostic counts, structured QA, response errors, and recommended actions.

You'll see one report generated from the latest export. The calculated counts and recommended actions now share the same source of truth.

Report missing evidence?

Confirm that annotations.json is in the same folder as audit.py.

If the report opens with empty sections, export the project again after submitting all six annotations.

Help me diagnose the generated report.

Capture the model and human views

A baseline screenshot shows what the model suggested. A reviewed screenshot shows the decision you made after applying the rubric.

  • Open the first task's imported prediction in Google Chrome.
  • Confirm that the incomplete October and screen spans are visible.
  • Press the Print Screen key to open GNOME's screenshot overlay.
  • Select the window capture mode.
  • Capture the Google Chrome window.
  • Open the first task's latest submitted annotation.
  • Confirm that 14 October and cracked screen are visible with the structured evaluation.
  • Capture the corrected Google Chrome window with the GNOME screenshot overlay.

Fitting the reviewed spans and response decision into one frame can be awkward. Reduce Chrome's zoom before the second capture if the lower fields are cut off.

  • Press the Super key to open GNOME Activities.
  • Type Files in the search field.
  • Open Files from the results.
  • Open the Pictures folder.
  • Open the Screenshots folder.
  • Rename the baseline image to task-before-correction.png.
  • Rename the reviewed image to task-after-correction.png.
Assemble the reproducibility package

The package combines source data, executable checks, generated outputs, and visual evidence. Account credentials and the local Label Studio database stay outside it.

  • Create the evidence folders and copy the reproducibility files by running:
mkdir -p evidence/screenshots
cp requirements.txt annotation_guidelines.md label_config.xml generate_tasks.py tasks.json annotations.json audit.py diagnostic_checks.py audit_report.md evidence/

What do these commands preserve?

  • mkdir -p creates the package folders without changing the source workspace.
  • cp preserves the exact inputs, scripts, checks, and generated report used by the audit.
  • Move task-before-correction.png from Pictures into ~/nlp-evaluation-audit/evidence/screenshots with GNOME Files.
  • Move task-after-correction.png into the same screenshots folder.
  • Before the final check, which files prove the path from model suggestion to human decision to generated recommendation?
  • List the packaged files by running:
ls evidence
ls evidence/screenshots

You'll see nine reproducibility files in evidence and two PNG files in evidence/screenshots. That is a complete audit trail another reviewer can inspect.

Package incomplete?

If a source file is missing, rerun the copy command from ~/nlp-evaluation-audit.

If a screenshot is missing, open Pictures and check its Screenshots folder before taking another capture.

Help me complete the evidence package.

Your audit evidence is complete. It connects the original predictions, human-reviewed annotations, executable diagnosis, generated report, and screenshots in one reproducible package.

Secret mission

Link Products to Issues

Your audit already records which products and issues appear in each ticket. Extend the interface with PRODUCT-to-ISSUE relations so your gold annotations also preserve which problem affects which product.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your Fedora Workstation at no ongoing cost. Choose whether to keep the lab running, pause its server, or delete its local resources.

Resources you used:

  • The running local Label Studio server from version 1.23.2.
  • The local NLP and LLM Support Audit project with six reviewed tasks.
  • The ~/nlp-evaluation-audit workspace containing .venv, source files, annotation exports, the audit report, and screenshots.

Keep everything running

No action needed. Choose this if you are still reviewing tasks or extending the audit.

  • Keep the terminal running so Label Studio remains available at http://localhost:8080.
  • Keep ~/nlp-evaluation-audit so the scripts, exports, report, evidence folder, and virtual environment remain available.

Pause - I'll come back to this later

Shut down the local server to free the terminal while preserving every project file and annotation.

  • Switch to the terminal running Label Studio.
  • Press Ctrl+C to stop the server.

You'll see the shell prompt return. The project database and audit files remain on your Fedora Workstation.

  • Restart the server later by running:
cd ~/nlp-evaluation-audit
source .venv/bin/activate
label-studio start

You'll see Label Studio start on http://localhost:8080 with the existing project still available.

Delete - I don't want to use this again

Remove the Label Studio project before stopping its server, then delete the local workspace. Copy anything you want to keep first because the final command is permanent.

  • Copy the evidence folder to another location if you want to keep the audit package.
  • Open the NLP and LLM Support Audit project in Google Chrome while the server is still running.
  • Click Settings in the project menu.
  • Select Danger Zone.
  • Click Delete Project.
  • Complete the confirmation prompt.

You'll return to the Projects page without the audit project. Label Studio has removed its tasks, annotations, and project data.

  • Switch to the terminal running Label Studio.
  • Press Ctrl+C to stop the local server.
  • Delete the complete workspace by running:
rm -rf ~/nlp-evaluation-audit

What does this command remove?

This command permanently removes the virtual environment, source files, exports, generated report, screenshots, and copied evidence package inside ~/nlp-evaluation-audit.

  • Press the Super key to open GNOME Activities.
  • Type Files in the search field.
  • Open Files.
  • Confirm that nlp-evaluation-audit no longer appears in your home folder.

Your Label Studio project, local server process, and audit workspace are now removed.

Nice Work!

Nice Work!

You made it. Your local evaluation lab turns synthetic support tickets into reproducible annotation QA evidence.

You've learned how to:

  • Built a multi-layer Label Studio workflow that corrects NER spans, classifies intent, compares LLM responses, and records evidence-based rationales.
  • Ran executable diagnostic checks that separate exact matches, wrong labels, boundary errors, and combined failures.
  • Generated a Markdown audit report and assembled its scripts, exports, report, and screenshots into a reproducible evidence package.
  • Secret Mission: Added relation extraction that links each PRODUCT region to its ISSUE region through AFFECTS.

Ready to quiz yourself?