top of page

Tutorial 2: Automated Grading with Rules

  • Contributor
  • 4 days ago
  • 3 min read

Rules are deterministic; cheap; don't require another LLM. Use them where they fit.

Step 1: Identify What Rules Can Check (5 min)

Rules work for:

  • Format: valid JSON, exact field names

  • Length: within bounds

  • Content: required substrings, forbidden words

  • Type: values within allowed set

  • Structure: correct nesting

Rules don't work for:

  • Subjective quality (writing style, helpfulness)

  • Nuanced correctness (factual accuracy beyond keyword match)

  • Tone

For subjective: see LLM-as-judge (Tutorial 3).

Step 2: Schema Validation (10 min)

For structured output:

from pydantic import BaseModel, ValidationError

class TicketAnalysis(BaseModel):
    category: Literal['billing', 'technical', 'account']
    urgency: Literal['low', 'medium', 'high']
    summary: str

def validate_output(output_text):
    try:
        data = json.loads(output_text)
        TicketAnalysis(**data)
        return {"valid_schema": True}
    except (json.JSONDecodeError, ValidationError) as e:
        return {"valid_schema": False, "error": str(e)}

Hard fail on schema violations. Common LLM failure mode.

Step 3: Length Checks (5 min)

def check_length(text, min_words=10, max_words=200):
    word_count = len(text.split())
    return min_words <= word_count <= max_words

LLMs often produce too long or too short outputs. Catch.

Step 4: Required Content (10 min)

def must_contain(output, required_terms):
    output_lower = output.lower()
    missing = [t for t in required_terms if t.lower() not in output_lower]
    return {"contains_all": len(missing) == 0, "missing": missing}

Output must reference specific facts from the input.

case = {
    "input": "Customer order #12345 didn't arrive",
    "expected": {"must_contain": ["12345"]}
}

If the summary loses the order number, it's wrong.

Step 5: Forbidden Content (5 min)

def must_not_contain(output, forbidden_terms):
    found = [t for t in forbidden_terms if t.lower() in output.lower()]
    return {"no_forbidden": len(found) == 0, "found": found}

For:

  • PII leakage (no SSN patterns in output)

  • Profanity

  • Competitor mentions

  • Things outside the model's scope

Step 6: Regex Patterns (5 min)

import re

def check_email_format(text):
    return bool(re.match(r'^[^@]+@[^@]+\.[^@]+$', text))

def has_pii(text):
    patterns = {
        "ssn": r'\d{3}-\d{2}-\d{4}',
        "credit_card": r'\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}',
        "phone": r'\d{3}[-.\s]?\d{3}[-.\s]?\d{4}',
    }
    for name, pattern in patterns.items():
        if re.search(pattern, text):
            return {"has_pii": True, "type": name}
    return {"has_pii": False}

Catches accidental PII leakage.

Step 7: Reference Comparisons (10 min)

For tasks with ground truth:

def compare_to_reference(output, reference):
    # Exact match
    if output.strip() == reference.strip():
        return {"exact_match": True}
    
    # Token overlap (BLEU, ROUGE)
    overlap = jaccard_similarity(output, reference)
    return {"overlap": overlap, "exact_match": False}

def jaccard_similarity(a, b):
    set_a = set(a.lower().split())
    set_b = set(b.lower().split())
    intersection = len(set_a & set_b)
    union = len(set_a | set_b)
    return intersection / union if union > 0 else 0

ROUGE for summarization; BLEU for translation. Imperfect; better than nothing.

Step 8: Combine Checks (10 min)

def grade_output(output, expected):
    grades = {}
    
    if "schema" in expected:
        grades.update(validate_schema(output, expected["schema"]))
    
    if "length" in expected:
        grades["length_ok"] = check_length(output, **expected["length"])
    
    if "must_contain" in expected:
        grades.update(must_contain(output, expected["must_contain"]))
    
    if "no_pii" in expected and expected["no_pii"]:
        grades.update(has_pii(output))
        grades["no_pii"] = not grades["has_pii"]
    
    grades["pass"] = all(v for k, v in grades.items() if isinstance(v, bool) and k != "pass")
    
    return grades

Composable; per-case configurable.

Step 9: Format-Aware Checks (5 min)

For specific output types:

def grade_json_output(output, expected_schema):
    try:
        data = json.loads(output)
    except:
        return {"valid": False}
    
    # Check expected fields
    missing = [f for f in expected_schema if f not in data]
    return {
        "valid": True,
        "missing_fields": missing,
        "has_extra": [k for k in data if k not in expected_schema],
    }

Specific to your output format.

Step 10: Limit Rules Where Subjective (5 min)

Don't try to grade "good writing" with rules. You'll fight the tool.

For subjective: use LLM-as-judge (Tutorial 3). For objective: rules win on speed, cost, determinism.

What You Just Did

Automated grading via rules. Cheap. Deterministic. Catches the obvious problems before they reach human review.

Common Failure Modes

Over-strict regex. Real outputs vary; regex too narrow.

Rules for subjective. "Good tone" can't be regex'd.

Forgotten edge cases. Empty outputs, null, unusual encoding.

Slow regex (catastrophic backtracking). Eval grinds to halt on certain inputs.

No clarity on what "pass" means. Different criteria; threshold unclear.

bottom of page