-
-
Notifications
You must be signed in to change notification settings - Fork 6.2k
fix(scripts): detect and auto-fix quiz answer-length bias using Claude API #307
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
javy0107
wants to merge
6
commits into
rohitg00:main
Choose a base branch
from
javy0107:fix/quiz-answer-length-bias
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 3 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
bcd974d
fix(scripts): add quiz answer-length bias validation script
javy0107 4970743
fix(scripts): add AI-powered quiz bias fixer using Claude API
javy0107 33bf278
fix(scripts): add timeout and broaden exception handling in fix_quiz_…
javy0107 82a7f97
fix(scripts): validate rewritten questions, remove unused os import, …
javy0107 633e826
fix(scripts): remove sys.argv API key path to avoid process exposure
javy0107 8c29b2c
fix(scripts): validate options elements are strings in is_valid_question
javy0107 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| #!/usr/bin/env python3 | ||
| """ | ||
| Check quiz.json files for answer-length bias. | ||
| Flags questions where the correct answer is significantly longer than distractors. | ||
| """ | ||
|
|
||
| import json | ||
| import os | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| THRESHOLD = 1.5 # correct answer is 1.5x longer than average distractor length | ||
|
|
||
|
|
||
| def check_quiz(path: Path) -> list[dict]: | ||
| issues = [] | ||
| try: | ||
| with open(path) as f: | ||
| data = json.load(f) | ||
| except (json.JSONDecodeError, OSError) as e: | ||
| print(f" ERROR reading {path}: {e}") | ||
| return issues | ||
|
|
||
| # handle both {"questions": [...]} and [...] root formats | ||
| if isinstance(data, list): | ||
| questions = data | ||
| elif isinstance(data, dict): | ||
| questions = data.get("questions", []) | ||
| else: | ||
| return issues | ||
|
|
||
| for i, q in enumerate(questions): | ||
| if not isinstance(q, dict): | ||
| continue | ||
|
|
||
| options = q.get("options", []) | ||
| correct = q.get("correct", -1) | ||
|
|
||
| if not options or correct < 0 or correct >= len(options): | ||
| continue | ||
|
|
||
| correct_len = len(options[correct]) | ||
| distractors = [len(options[j]) for j in range(len(options)) if j != correct] | ||
|
|
||
| if not distractors: | ||
| continue | ||
|
|
||
| avg_distractor_len = sum(distractors) / len(distractors) | ||
|
|
||
| if avg_distractor_len > 0 and correct_len / avg_distractor_len > THRESHOLD: | ||
| issues.append({ | ||
| "file": str(path), | ||
| "question_index": i, | ||
| "question": q.get("question", "")[:80], | ||
| "correct_answer": options[correct][:80], | ||
| "correct_len": correct_len, | ||
| "avg_distractor_len": round(avg_distractor_len, 1), | ||
| "ratio": round(correct_len / avg_distractor_len, 2), | ||
| }) | ||
|
|
||
| return issues | ||
|
|
||
|
|
||
| def main(): | ||
| root = Path(__file__).parent.parent | ||
| quiz_files = sorted(root.rglob("quiz.json")) | ||
|
|
||
| print(f"Scanning {len(quiz_files)} quiz files...\n") | ||
|
|
||
| all_issues = [] | ||
| for path in quiz_files: | ||
| issues = check_quiz(path) | ||
| all_issues.extend(issues) | ||
|
|
||
| if not all_issues: | ||
| print("No answer-length bias detected.") | ||
| sys.exit(0) | ||
|
|
||
| print(f"Found {len(all_issues)} biased question(s):\n") | ||
| for issue in all_issues: | ||
| print(f" File : {issue['file']}") | ||
| print(f" Q #{issue['question_index']}: {issue['question']}") | ||
| print(f" Correct: {issue['correct_answer']}") | ||
| print(f" Ratio : {issue['ratio']}x (correct={issue['correct_len']} chars, avg distractor={issue['avg_distractor_len']} chars)") | ||
| print() | ||
|
|
||
| print(f"Total biased questions: {len(all_issues)}") | ||
| sys.exit(1) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| #!/usr/bin/env python3 | ||
| """ | ||
| Fix answer-length bias in quiz.json files using Claude API. | ||
| Set ANTHROPIC_API_KEY environment variable before running. | ||
|
|
||
| Usage: | ||
| export ANTHROPIC_API_KEY=your_key_here | ||
| python3 scripts/fix_quiz_bias.py | ||
| """ | ||
|
|
||
| import json | ||
| import os | ||
| import sys | ||
| import urllib.request | ||
| import urllib.error | ||
| from pathlib import Path | ||
|
|
||
| THRESHOLD = 1.5 | ||
| API_URL = "https://api.anthropic.com/v1/messages" | ||
| MODEL = "claude-sonnet-4-6" | ||
| TIMEOUT = 30 # seconds | ||
|
|
||
|
|
||
| def get_api_key() -> str: | ||
| key = os.environ.get("ANTHROPIC_API_KEY", "") | ||
| if not key: | ||
| print("Error: ANTHROPIC_API_KEY environment variable not set.") | ||
| print("Usage: export ANTHROPIC_API_KEY=your_key_here") | ||
| sys.exit(1) | ||
| return key | ||
|
|
||
|
|
||
| def is_biased(options: list[str], correct: int) -> bool: | ||
| if not options or correct < 0 or correct >= len(options): | ||
| return False | ||
| correct_len = len(options[correct]) | ||
| distractors = [len(options[j]) for j in range(len(options)) if j != correct] | ||
| if not distractors: | ||
| return False | ||
| avg = sum(distractors) / len(distractors) | ||
| return avg > 0 and correct_len / avg > THRESHOLD | ||
|
|
||
|
|
||
| def rewrite_question(question: dict, api_key: str) -> dict: | ||
| prompt = f"""You are fixing a quiz question that has answer-length bias. | ||
| The correct answer is significantly longer than the wrong answers, which lets test-takers guess by length alone. | ||
|
|
||
| Original question: | ||
| {json.dumps(question, indent=2)} | ||
|
|
||
| Rewrite the options so all answers are roughly similar in length. | ||
| Keep the meaning and correctness intact. Keep the same correct index. | ||
| Return only valid JSON in exactly this format, nothing else: | ||
| {{ | ||
| "stage": "{question.get('stage', 'check')}", | ||
| "question": "...", | ||
| "options": ["a", "b", "c", "d"], | ||
| "correct": {question.get('correct', 0)}, | ||
| "explanation": "..." | ||
| }}""" | ||
|
|
||
| payload = json.dumps({ | ||
| "model": MODEL, | ||
| "max_tokens": 1024, | ||
| "messages": [{"role": "user", "content": prompt}] | ||
| }).encode() | ||
|
|
||
| req = urllib.request.Request( | ||
| API_URL, | ||
| data=payload, | ||
| headers={ | ||
| "Content-Type": "application/json", | ||
| "x-api-key": api_key, | ||
| "anthropic-version": "2023-06-01", | ||
| }, | ||
| method="POST" | ||
| ) | ||
|
|
||
| try: | ||
| with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: | ||
| data = json.loads(resp.read()) | ||
| text = data["content"][0]["text"].strip() | ||
| if text.startswith("```"): | ||
| text = text.split("```")[1] | ||
| if text.startswith("json"): | ||
| text = text[4:] | ||
| return json.loads(text.strip()) | ||
| except urllib.error.HTTPError as e: | ||
| print(f" HTTP error {e.code}: {e.reason} — keeping original") | ||
| except urllib.error.URLError as e: | ||
| print(f" Network error: {e.reason} — keeping original") | ||
| except (KeyError, IndexError) as e: | ||
| print(f" Unexpected response shape: {e} — keeping original") | ||
| except json.JSONDecodeError as e: | ||
| print(f" Failed to parse API response as JSON: {e} — keeping original") | ||
| except Exception as e: | ||
| print(f" Unexpected error: {e} — keeping original") | ||
|
|
||
| return question | ||
|
|
||
|
|
||
| def fix_quiz(path: Path, api_key: str) -> int: | ||
| try: | ||
| with open(path) as f: | ||
| data = json.load(f) | ||
| except (json.JSONDecodeError, OSError) as e: | ||
| print(f" ERROR reading {path}: {e}") | ||
| return 0 | ||
|
|
||
| if isinstance(data, list): | ||
| questions = data | ||
| elif isinstance(data, dict): | ||
| questions = data.get("questions", []) | ||
| else: | ||
| return 0 | ||
|
|
||
| fixed = 0 | ||
| for i, q in enumerate(questions): | ||
| if not isinstance(q, dict): | ||
| continue | ||
| options = q.get("options", []) | ||
| correct = q.get("correct", -1) | ||
| if is_biased(options, correct): | ||
| print(f" Fixing Q#{i}: {q.get('question', '')[:60]}...") | ||
| questions[i] = rewrite_question(q, api_key) | ||
| fixed += 1 | ||
|
|
||
| try: | ||
| with open(path, "w") as f: | ||
| json.dump(data, f, indent=2) | ||
| f.write("\n") | ||
| except OSError as e: | ||
| print(f" ERROR writing {path}: {e}") | ||
|
|
||
| return fixed | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
|
|
||
| def main(): | ||
| api_key = get_api_key() | ||
| root = Path(__file__).parent.parent | ||
| quiz_files = sorted(root.rglob("quiz.json")) | ||
|
|
||
| print(f"Scanning {len(quiz_files)} quiz files...\n") | ||
|
|
||
| total_fixed = 0 | ||
| for path in quiz_files: | ||
| try: | ||
| with open(path) as f: | ||
| data = json.load(f) | ||
| except (json.JSONDecodeError, OSError): | ||
| continue | ||
|
|
||
| if isinstance(data, list): | ||
| questions = data | ||
| elif isinstance(data, dict): | ||
| questions = data.get("questions", []) | ||
| else: | ||
| continue | ||
|
|
||
| biased = [ | ||
| q for q in questions | ||
| if isinstance(q, dict) and is_biased(q.get("options", []), q.get("correct", -1)) | ||
| ] | ||
|
|
||
| if biased: | ||
| print(f"Fixing {len(biased)} question(s) in {path.relative_to(root)}") | ||
| total_fixed += fix_quiz(path, api_key) | ||
|
|
||
| print(f"\nDone. Fixed {total_fixed} biased questions.") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor
🧩 Analysis chain
🏁 Script executed:
Repository: rohitg00/ai-engineering-from-scratch
Length of output: 174
🏁 Script executed:
Repository: rohitg00/ai-engineering-from-scratch
Length of output: 3042
Remove unused
osimport on line 8.The
osmodule is imported but never used in the file. Additionally, line 15 uses thelist[dict]type hint syntax (PEP 585), which requires Python 3.9+.Suggested fix
import json -import os import sys🤖 Prompt for AI Agents