"""Ask Claude to interpret flagged items and draft revisions. The statistics in itemstats.py tell you *that* an item misbehaves. This step asks Claude *why*, by reading the item text next to the numbers, and asks for a concrete next step: keep it, rekey it, revise it, or retire it. Claude's answer comes back as structured output, so every review has the same fields and can go straight into the report. """ from __future__ import annotations import json from dataclasses import asdict from typing import Literal import anthropic from pydantic import BaseModel, Field from itemstats import ItemStats MODEL = "claude-opus-5-5" SYSTEM_PROMPT = """You are an assessment specialist helping an instructional designer review a \ multiple-choice knowledge check. You will get one item: its learning objective, stem, options, \ answer key, and classical item statistics computed from real learner responses, plus flags the \ script raised. How to read the statistics: - difficulty (p) is the share who answered correctly. Above 0.90 is very easy, below 0.30 is very hard. - discrimination is the corrected point-biserial between the item and the rest of the test. \ Above 0.30 is good, 0.20 to 0.30 is acceptable, below 0.20 is weak, and negative means weaker \ learners did better than stronger ones. - For each option you get its share overall, its share in the top and bottom 27% of scorers, and \ its point-biserial with the rest of the test. A good distractor has a negative point-biserial and \ attracts more of the lower group. A wrong option with a positive point-biserial, or one the top \ group prefers over the key, usually means a miskey or a second defensible answer. Your job: 1. Read the item text as a subject-matter expert would. Decide whether the stem, the key, and \ each option are actually correct and unambiguous. The statistics are evidence, not the whole story. 2. Name the most likely cause of the problem. 3. Recommend one action and, unless the action is keep or retire, write a revised item that fixes \ the cause while still testing the same objective. Write for a busy instructional designer: plain language, short sentences, no jargon beyond the \ terms above. Do not invent statistics you were not given.""" class RevisedOption(BaseModel): letter: Literal["A", "B", "C", "D"] text: str class RevisedItem(BaseModel): stem: str options: list[RevisedOption] = Field(description="Exactly four options, A through D") key: Literal["A", "B", "C", "D"] class ItemReview(BaseModel): item_id: str likely_cause: Literal[ "miskey", "ambiguous_or_two_correct_answers", "too_easy", "too_hard", "weak_distractors", "confusing_wording", "no_real_problem", ] diagnosis: str = Field(description="Two to four sentences explaining what the numbers and the text show together") action: Literal["keep", "rekey", "revise", "retire"] revised_item: RevisedItem | None = Field( default=None, description="Required when action is rekey or revise" ) what_changed: str = Field(description="One or two sentences on what the revision changes and why") def build_item_payload(item: dict, stats: ItemStats) -> str: """Everything Claude needs about one item, as a JSON string.""" payload = { "item": item, "statistics": { "difficulty_p": round(stats.difficulty, 3), "discrimination_r": None if stats.discrimination is None else round(stats.discrimination, 3), "d_index": round(stats.d_index, 3), "options": [ {k: (round(v, 3) if isinstance(v, float) else v) for k, v in asdict(o).items()} for o in stats.options ], }, "flags": stats.flags, } return json.dumps(payload, indent=2) def review_item(client: anthropic.Anthropic, item: dict, stats: ItemStats) -> ItemReview | None: """Send one flagged item to Claude and get back a structured review.""" response = client.messages.parse( model=MODEL, max_tokens=16000, system=SYSTEM_PROMPT, output_config={"effort": "medium"}, output_format=ItemReview, messages=[ { "role": "user", "content": "Review this item and recommend what to do with it.\n\n" + build_item_payload(item, stats), } ], ) if response.stop_reason == "refusal": print(f" {stats.item_id}: Claude declined to review this item, skipping it.") return None if response.stop_reason == "max_tokens": print(f" {stats.item_id}: the review was cut off by max_tokens, skipping it.") return None return response.parsed_output def review_flagged_items(quiz: dict, item_stats: list[ItemStats]) -> list[ItemReview]: client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment items_by_id = {item["id"]: item for item in quiz["items"]} reviews = [] for stats in item_stats: if not stats.flags: continue print(f" Asking Claude about {stats.item_id}...") try: review = review_item(client, items_by_id[stats.item_id], stats) except anthropic.RateLimitError: # The SDK already retried with backoff before raising this. print(" Still rate limited after retries. Try again in a minute.") break except anthropic.APIStatusError as e: print(f" {stats.item_id}: API error {e.status_code}, skipping this item.") continue except anthropic.APIConnectionError: print(" Could not reach the API. Check your network connection.") break if review is not None: review.item_id = stats.item_id # trust our own id over the model's copy reviews.append(review) return reviews