Buckets:
| """Case loading, prompt building, answer parsing and metrics for the pilot. | |
| The notebook embeds this file's source in one cell (scripts/build_notebook.py), so | |
| Colab needs no repo clone. Model loading and generation live in the notebook. | |
| """ | |
| import csv | |
| import json | |
| import re | |
| from collections import defaultdict | |
| STATUS_LABELS = ("CONFIRMED_ACTIVE", "CONFIRMED_NOT_ACTIVE", "INSUFFICIENT_EVIDENCE") | |
| TASK_TYPES = ("status", "order", "extract") | |
| LANGUAGES = ("fr", "darija_fr") | |
| CASE_COLUMNS = ( | |
| "family_id", "variant_id", "language", "task_type", "fictional_rule", "narrative", | |
| "question", "gold_answer", "gold_rationale", "changed_feature", "same_answer_as_base", | |
| "written_by", "review_status", "notes", | |
| ) | |
| ANSWER_INSTRUCTIONS = { | |
| "status": "End your reply with one line: ANSWER: CONFIRMED_ACTIVE, ANSWER: CONFIRMED_NOT_ACTIVE or ANSWER: INSUFFICIENT_EVIDENCE.", | |
| "order": "End your reply with one line: ANSWER: followed by the event IDs in chronological order, comma-separated (for example ANSWER: E2, E1, E3).", | |
| "extract": "End your reply with one line: ANSWER: followed by your short answer.", | |
| } | |
| # Two fixed layouts with identical content; only formatting differs (prompt-format check). | |
| PROMPT_FORMATS = { | |
| "A": "Rule: {rule}\n\nStory: {narrative}\n\nQuestion: {question}\n\n{instruction}", | |
| "B": "### RULE\n{rule}\n### STORY\n{narrative}\n### QUESTION\n{question}\n### FORMAT\n{instruction}", | |
| } | |
| def case_id(case): | |
| return f"{case['family_id']}-{case['variant_id']}-{case['language']}" | |
| def load_cases(path, approved_only=True): | |
| """Read the review sheet CSV; keep APPROVED/EDITED rows unless approved_only is False.""" | |
| with open(path, encoding="utf-8-sig", newline="") as f: | |
| rows = list(csv.DictReader(f)) | |
| missing = [c for c in CASE_COLUMNS if rows and c not in rows[0]] | |
| if missing: | |
| raise ValueError(f"cases file is missing columns: {missing}") | |
| cases, problems = [], [] | |
| for n, row in enumerate(rows, start=2): | |
| row = {k: (v or "").strip() for k, v in row.items() if k} | |
| if not row.get("family_id"): | |
| continue | |
| if approved_only and row["review_status"].upper() not in ("APPROVED", "EDITED"): | |
| continue | |
| if row["task_type"] not in TASK_TYPES: | |
| problems.append(f"line {n}: task_type {row['task_type']!r} not in {TASK_TYPES}") | |
| if row["language"] not in LANGUAGES: | |
| problems.append(f"line {n}: language {row['language']!r} not in {LANGUAGES}") | |
| if row["task_type"] == "status" and row["gold_answer"] not in STATUS_LABELS: | |
| problems.append(f"line {n}: status gold {row['gold_answer']!r} not in {STATUS_LABELS}") | |
| row["case_id"] = case_id(row) | |
| cases.append(row) | |
| ids = [c["case_id"] for c in cases] | |
| dupes = sorted({i for i in ids if ids.count(i) > 1}) | |
| if dupes: | |
| problems.append(f"duplicate case ids: {dupes}") | |
| if problems: | |
| raise ValueError("cases file problems:\n" + "\n".join(problems)) | |
| return cases | |
| GLOSSARY_BLOCKS = { | |
| "A": "Glossary of Darija words: {glossary}\n\nStory:", | |
| "B": "### GLOSSARY OF DARIJA WORDS\n{glossary}\n### STORY", | |
| } | |
| def build_prompt(case, fmt, glossary=None): | |
| """With a glossary, the same prompt gets a glossary section just before the story.""" | |
| prompt = PROMPT_FORMATS[fmt].format( | |
| rule=case["fictional_rule"], narrative=case["narrative"], question=case["question"], | |
| instruction=ANSWER_INSTRUCTIONS[case["task_type"]], | |
| ) | |
| if glossary: | |
| marker = "Story:" if fmt == "A" else "### STORY" | |
| prompt = prompt.replace(marker, GLOSSARY_BLOCKS[fmt].format(glossary=glossary.strip()), 1) | |
| return prompt | |
| def load_glossary(path): | |
| text = open(path, encoding="utf-8-sig").read().strip() | |
| lines = [l for l in text.splitlines() if l.strip() and not l.lstrip().startswith("#")] | |
| if not lines or any("TODO" in l for l in lines): | |
| raise ValueError(f"{path} still has TODO lines or is empty; write the glossary first") | |
| return "\n".join(lines) | |
| def final_text(raw): | |
| """Drop any thinking block; only the text after </think> is scored.""" | |
| return raw.rsplit("</think>", 1)[-1].strip() | |
| def parse_answer(raw, task_type): | |
| """Return (parsed_answer, parse_status). parse_status: ok | no_answer_line | invalid.""" | |
| text = final_text(raw) | |
| lines = [l for l in text.splitlines() if re.match(r"\s*\**\s*ANSWER\s*\**\s*:", l, re.I)] | |
| if not lines: | |
| return "", "no_answer_line" | |
| value = lines[-1].split(":", 1)[1].strip().strip("*").strip() | |
| if task_type == "status": | |
| found = [lab for lab in STATUS_LABELS if re.search(rf"\b{lab}\b", value.upper())] | |
| return (found[0], "ok") if len(found) == 1 else (value, "invalid") | |
| if task_type == "order": | |
| ids = re.findall(r"\bE\d+\b", value.upper()) | |
| return (", ".join(ids), "ok") if ids else (value, "invalid") | |
| return value, "ok" | |
| def normalise_order(s): | |
| return [x for x in re.findall(r"E\d+", s.upper())] | |
| def auto_score(case, parsed, parse_status): | |
| """True/False when checkable automatically, None when a human must judge (extract tasks).""" | |
| if parse_status != "ok": | |
| return False | |
| if case["task_type"] == "status": | |
| return parsed == case["gold_answer"] | |
| if case["task_type"] == "order": | |
| return normalise_order(parsed) == normalise_order(case["gold_answer"]) | |
| return None | |
| def final_correct(row): | |
| """The human verdict wins over the automatic score.""" | |
| manual = (row.get("manual_verdict") or "").strip().upper() | |
| if manual in ("CORRECT", "WRONG"): | |
| return manual == "CORRECT" | |
| auto = row.get("auto_correct") | |
| if auto in (True, False): | |
| return auto | |
| if isinstance(auto, str) and auto in ("True", "False"): | |
| return auto == "True" | |
| return None | |
| def _ratio(rows): | |
| judged = [r for r in rows if final_correct(r) is not None] | |
| return f"{sum(final_correct(r) for r in judged)}/{len(judged)}" | |
| def metrics(rows): | |
| """Counts as numerator/denominator strings; unjudged rows are left out of each ratio.""" | |
| out = {"overall": _ratio(rows), "unjudged": sum(final_correct(r) is None for r in rows)} | |
| for key in ("language", "prompt_format", "task_type", "variant_id"): | |
| groups = defaultdict(list) | |
| for r in rows: | |
| groups[r[key]].append(r) | |
| out[f"by_{key}"] = {k: _ratio(v) for k, v in sorted(groups.items())} | |
| out["parse_failures"] = sum(r["parse_status"] != "ok" for r in rows) | |
| by_id = {(r["family_id"], r["variant_id"], r["language"], r["prompt_format"]): r for r in rows} | |
| # Cross-language consistency: same case and format, French vs Darija-French. | |
| same, both_right, pairs = 0, 0, 0 | |
| for (fam, var, lang, fmt), r in by_id.items(): | |
| other = by_id.get((fam, var, "darija_fr", fmt)) if lang == "fr" else None | |
| if not other: | |
| continue | |
| a, b = final_correct(r), final_correct(other) | |
| if a is None or b is None: | |
| continue | |
| pairs += 1 | |
| same += (r["parsed_answer"].upper() == other["parsed_answer"].upper()) | |
| both_right += a and b | |
| out["cross_language_same_answer"] = f"{same}/{pairs}" | |
| out["cross_language_both_correct"] = f"{both_right}/{pairs}" | |
| # Contrast consistency: base and contrast both correct, per family, language and format. | |
| groups, ok = 0, 0 | |
| for (fam, var, lang, fmt), r in by_id.items(): | |
| if var != "base": | |
| continue | |
| twin = by_id.get((fam, "contrast", lang, fmt)) | |
| if not twin or final_correct(r) is None or final_correct(twin) is None: | |
| continue | |
| groups += 1 | |
| ok += final_correct(r) and final_correct(twin) | |
| out["contrast_consistency"] = f"{ok}/{groups}" | |
| return out | |
| def append_jsonl(path, record): | |
| with open(path, "a", encoding="utf-8") as f: | |
| f.write(json.dumps(record, ensure_ascii=False) + "\n") | |
| def read_jsonl(path): | |
| try: | |
| with open(path, encoding="utf-8") as f: | |
| return [json.loads(l) for l in f if l.strip()] | |
| except FileNotFoundError: | |
| return [] | |
Xet Storage Details
- Size:
- 8.31 kB
- Xet hash:
- 89cda08436d215f7ee483fcb9568fc7e94a687b4d7f5b6eb875f9431471a904f
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.