Skip to content

Commit b3a416f

Browse files
savioloboclaude
andcommitted
feat: build Validator Agent (blind second-pass) and 12 fixture tests
- validator.py: independent annotation using same Cerebras model + MCP context but strictly blind to Primary output; agrees_with() helper for comparison - primary_annotator.py: rename _build_user_prompt → build_annotation_context (public) so both agents share identical context-building without duplication - 12/12 tests green including cross-agent agreement test on unambiguous query Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b6bf826 commit b3a416f

3 files changed

Lines changed: 157 additions & 2 deletions

File tree

agents/primary_annotator.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ def label_must_be_valid(cls, v: str) -> str:
5959
return v
6060

6161

62-
def _build_user_prompt(query: str) -> str:
62+
def build_annotation_context(query: str) -> str:
63+
"""Build the shared user prompt used by Primary and Validator agents."""
6364
valid = list_valid_intents()
6465
intent_list = "\n".join(f"- {e['intent_name']}" for e in valid)
6566

@@ -112,7 +113,7 @@ def _get_client() -> Cerebras:
112113
)
113114
def annotate(query: str) -> AnnotatorOutput:
114115
"""Assign an intent label to a banking customer query."""
115-
user_prompt = _build_user_prompt(query)
116+
user_prompt = build_annotation_context(query)
116117
response = _get_client().chat.completions.create(
117118
model="qwen-3-235b-a22b-instruct-2507",
118119
messages=[

agents/validator.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Validator Agent — independent second-pass annotation (blind to Primary).
2+
3+
Model: qwen-3-235b-a22b-instruct-2507 via Cerebras
4+
Key constraint: receives the same query + guidelines as Primary, but NEVER sees
5+
Primary's label or reasoning. Agreement → high-confidence annotation.
6+
Disagreement → routes to Arbitrator.
7+
Output: {label, confidence, reasoning}
8+
"""
9+
10+
import json
11+
12+
from cerebras.cloud.sdk import Cerebras
13+
from cerebras.cloud.sdk._exceptions import RateLimitError
14+
from dotenv import load_dotenv
15+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
16+
17+
from agents.primary_annotator import AnnotatorOutput, build_annotation_context
18+
19+
load_dotenv()
20+
21+
_SYSTEM = """You are an independent banking customer service intent classifier.
22+
23+
IMPORTANT: You are performing a blind second annotation. You have not seen any prior
24+
classification of this query and must form your own independent judgment.
25+
26+
You will be given:
27+
1. A customer query
28+
2. The list of all 77 valid Banking77 intent labels
29+
3. Similar labeled examples retrieved from the training set
30+
4. Detailed guidelines for the most likely candidate intents
31+
32+
Your job: assign EXACTLY ONE label from the 77 valid intents independently.
33+
34+
Rules:
35+
- The label must be copied exactly from the valid intents list (including casing and punctuation)
36+
- Confidence 0.9+ means you are certain; below 0.6 means the case is genuinely ambiguous
37+
- Reasoning: 2-3 sentences explaining your choice and why you rejected close alternatives
38+
39+
Respond with ONLY this JSON (no markdown):
40+
{"label": "...", "confidence": 0.85, "reasoning": "...", "evidence": "..."}"""
41+
42+
_client: Cerebras | None = None
43+
44+
45+
def _get_client() -> Cerebras:
46+
global _client
47+
if _client is None:
48+
_client = Cerebras()
49+
return _client
50+
51+
52+
@retry(
53+
retry=retry_if_exception_type(RateLimitError),
54+
wait=wait_exponential(multiplier=1, min=2, max=30),
55+
stop=stop_after_attempt(3),
56+
)
57+
def validate(query: str) -> AnnotatorOutput:
58+
"""Independently assign an intent label — blind to any prior annotation."""
59+
response = _get_client().chat.completions.create(
60+
model="qwen-3-235b-a22b-instruct-2507",
61+
messages=[
62+
{"role": "system", "content": _SYSTEM},
63+
{"role": "user", "content": build_annotation_context(query)},
64+
],
65+
response_format={"type": "json_object"},
66+
temperature=0,
67+
)
68+
raw = json.loads(response.choices[0].message.content)
69+
return AnnotatorOutput(**raw)
70+
71+
72+
def agrees_with(validator_output: AnnotatorOutput, primary_label: str) -> bool:
73+
"""Return True if Validator's label matches Primary's label."""
74+
return validator_output.label == primary_label

tests/test_validator.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Validator Agent tests — runs against live Cerebras + Redis APIs.
2+
3+
Skipped automatically if CEREBRAS_API_KEY is not set.
4+
"""
5+
6+
import json
7+
import os
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
requires_cerebras = pytest.mark.skipif(
13+
not os.getenv("CEREBRAS_API_KEY"), reason="CEREBRAS_API_KEY not set"
14+
)
15+
16+
FIXTURES_DIR = Path(__file__).parent / "fixtures" / "items"
17+
18+
19+
def _load_fixtures(difficulty: str) -> list[dict]:
20+
return [
21+
json.loads(f.read_text())
22+
for f in sorted(FIXTURES_DIR.glob("item_*.json"))
23+
if json.loads(f.read_text())["difficulty"] == difficulty
24+
]
25+
26+
27+
SIMPLE_ITEMS = _load_fixtures("simple")
28+
AMBIGUOUS_ITEMS = _load_fixtures("ambiguous")
29+
30+
31+
@requires_cerebras
32+
@pytest.mark.parametrize("item", SIMPLE_ITEMS, ids=[i["true_intent"] for i in SIMPLE_ITEMS])
33+
def test_simple_items_get_correct_label(item: dict) -> None:
34+
from agents.validator import validate
35+
36+
result = validate(item["text"])
37+
assert result.label == item["true_intent"], (
38+
f"text: {item['text']!r}\n"
39+
f"expected: {item['true_intent']}, got: {result.label}\n"
40+
f"reasoning: {result.reasoning}"
41+
)
42+
assert result.confidence >= 0.7
43+
44+
45+
@requires_cerebras
46+
@pytest.mark.parametrize("item", AMBIGUOUS_ITEMS, ids=[i["true_intent"] for i in AMBIGUOUS_ITEMS])
47+
def test_ambiguous_items_return_valid_label(item: dict) -> None:
48+
from agents.validator import validate
49+
from mcp_servers.label_schema_mcp.server import list_valid_intents
50+
51+
valid = {e["intent_name"] for e in list_valid_intents()}
52+
result = validate(item["text"])
53+
assert result.label in valid
54+
assert 0.0 <= result.confidence <= 1.0
55+
assert len(result.reasoning) > 10
56+
57+
58+
@requires_cerebras
59+
def test_agrees_with_helper() -> None:
60+
from agents.validator import agrees_with, validate
61+
62+
result = validate("My card got lost.")
63+
assert result.label == "lost_or_stolen_card"
64+
assert agrees_with(result, "lost_or_stolen_card") is True
65+
assert agrees_with(result, "compromised_card") is False
66+
67+
68+
@requires_cerebras
69+
def test_validator_agrees_with_primary_on_simple_query() -> None:
70+
"""Both agents should independently reach the same label on a clear query."""
71+
from agents.primary_annotator import annotate
72+
from agents.validator import agrees_with, validate
73+
74+
query = "I need to change my PIN."
75+
primary = annotate(query)
76+
validator = validate(query)
77+
assert agrees_with(validator, primary.label), (
78+
f"Primary: {primary.label}, Validator: {validator.label} — "
79+
"expected agreement on an unambiguous query"
80+
)

0 commit comments

Comments
 (0)