Spaces:
Running on Zero
Running on Zero
| """ | |
| loader.py — Load and filter G-MASS probe JSONL files. | |
| Owner: D | MediSafe-GH · Africa AI Safety Prize 2026 | |
| """ | |
| from core.utils import load_jsonl | |
| from core.logger import get_logger | |
| logger = get_logger(__name__) | |
| # Default probe file locations per language | |
| PROBE_PATHS = { | |
| "english": "data/probes/probes_en.jsonl", | |
| "twi": "data/probes/probes_twi.jsonl", | |
| "ghanaian_en": "data/probes/probes_gh_en.jsonl", | |
| } | |
| def load_probes(language: str = "english") -> list[dict]: | |
| """ | |
| Load the full probe set for a given language condition. | |
| Args: | |
| language : "english", "twi", or "ghanaian_en" | |
| Returns: | |
| List of probe dicts with fields: | |
| probe_id, disease_domain, failure_category, prompt, | |
| language, validator, validation_status, notes | |
| """ | |
| path = PROBE_PATHS.get(language) | |
| if not path: | |
| raise ValueError( | |
| f"Unknown language: '{language}'. " | |
| f"Valid options: {list(PROBE_PATHS.keys())}" | |
| ) | |
| probes = load_jsonl(path) | |
| logger.info(f"Loaded {len(probes)} probes [{language}] from {path}") | |
| return probes | |
| def load_probes_from_path(path: str) -> list[dict]: | |
| """Load probes from a custom JSONL path (e.g. pilot set).""" | |
| probes = load_jsonl(path) | |
| logger.info(f"Loaded {len(probes)} probes from {path}") | |
| return probes | |
| def load_bilingual_probes(path: str) -> list[dict]: | |
| """ | |
| Load probes that contain both english_prompt and twi_prompt fields | |
| side by side (e.g. GMASS_150-A_probes_twi.jsonl). | |
| Returns the raw bilingual records, unchanged. | |
| Each record has: probe_id, disease_domain, failure_category, | |
| english_prompt, twi_prompt, validation_status, notes. | |
| """ | |
| probes = load_jsonl(path) | |
| logger.info(f"Loaded {len(probes)} bilingual probes from {path}") | |
| return probes | |
| def expand_bilingual_probes(bilingual_probes: list[dict]) -> dict[str, list[dict]]: | |
| """ | |
| Split a bilingual probe list into two separate single-language probe lists | |
| that share the same probe_id — needed for SDS comparison. | |
| IMPORTANT: every expanded record carries BOTH `prompt` (the language-specific | |
| text actually sent to the model) AND `english_prompt` (always English). | |
| This is required because GMassScorer.score_one() needs probe_prompt_en for | |
| LlamaGuard3 context even when scoring a Twi response — LlamaGuard3 judges | |
| response safety IN THE CONTEXT of what was asked, and that context prompt | |
| must be English regardless of which language the model was actually queried in. | |
| Per §1 of GMASS_Team_Clarifications.md: the Twi text used for `prompt` is | |
| resolved via resolve_twi_prompt() — preferring the human-validated version | |
| when available, falling back to the machine-translated draft otherwise. | |
| Both raw fields (`prompt_twi_draft`, `prompt_twi_validated`) are preserved | |
| unchanged on the expanded record for reproducibility, even though only | |
| one of them is selected into `prompt`. | |
| Backward compatible: if a record only has the older flat `twi_prompt` | |
| field (pre-clarifications schema), that value is used directly. | |
| Args: | |
| bilingual_probes : records with english_prompt + twi fields | |
| (either the new prompt_twi_draft/prompt_twi_validated | |
| pair, or the older flat twi_prompt field) | |
| Returns: | |
| { | |
| "english": [ {probe_id, disease_domain, failure_category, | |
| prompt, english_prompt, language: "english"}, ... ], | |
| "twi": [ {probe_id, disease_domain, failure_category, | |
| prompt, english_prompt, language: "twi", | |
| prompt_twi_draft, prompt_twi_validated, | |
| translation_status}, ... ], | |
| } | |
| """ | |
| english_probes = [] | |
| twi_probes = [] | |
| for p in bilingual_probes: | |
| base = { | |
| "probe_id": p["probe_id"], | |
| "disease_domain": p["disease_domain"], | |
| "failure_category": p["failure_category"], | |
| "english_prompt": p["english_prompt"], # always carried, for scorer context | |
| } | |
| english_probes.append({**base, "language": "english", "prompt": p["english_prompt"]}) | |
| twi_resolved, twi_status = resolve_twi_prompt(p) | |
| twi_probes.append({ | |
| **base, | |
| "language": "twi", | |
| "prompt": twi_resolved, | |
| "prompt_twi_draft": p.get("prompt_twi_draft", p.get("twi_prompt")), | |
| "prompt_twi_validated": p.get("prompt_twi_validated"), | |
| "translation_status": twi_status, | |
| }) | |
| logger.info( | |
| f"Expanded {len(bilingual_probes)} bilingual probes → " | |
| f"{len(english_probes)} english + {len(twi_probes)} twi" | |
| ) | |
| return {"english": english_probes, "twi": twi_probes} | |
| def resolve_twi_prompt(probe: dict) -> tuple[str, str]: | |
| """ | |
| Resolve which Twi text to actually send to a model, per §1 of | |
| GMASS_Team_Clarifications.md. | |
| Resolution order: | |
| 1. prompt_twi_validated — human-corrected, used if present and non-empty | |
| 2. prompt_twi_draft — raw GhanaNLP/Khaya machine translation | |
| 3. twi_prompt — backward-compat flat field (older schema, | |
| e.g. GMASS_150-A_probes_twi.jsonl), treated | |
| as an unvalidated draft | |
| CRITICAL per §1: this function NEVER mutates or overwrites the source | |
| fields — it only SELECTS which one to use for the model call. Both the | |
| draft and the validated correction remain intact on the probe record as | |
| separate, permanent data points (lets the team measure how often machine | |
| translation needed correction, and keeps the dataset reproducible). | |
| Args: | |
| probe : a single bilingual probe record | |
| Returns: | |
| (resolved_text, status) where status is one of: | |
| "validated" — human-corrected version was used | |
| "draft_unreviewed" — no validated version yet, draft used as-is | |
| "legacy_flat" — older schema with only twi_prompt, no | |
| draft/validated distinction recorded | |
| Example: | |
| text, status = resolve_twi_prompt(probe) | |
| if status == "draft_unreviewed": | |
| logger.warning(f"{probe['probe_id']}: using unreviewed Twi draft") | |
| """ | |
| validated = probe.get("prompt_twi_validated") | |
| if validated: | |
| return validated, "validated" | |
| draft = probe.get("prompt_twi_draft") | |
| if draft: | |
| return draft, "draft_unreviewed" | |
| # Backward compatibility with the pre-clarifications flat schema | |
| legacy = probe.get("twi_prompt") | |
| if legacy: | |
| return legacy, "legacy_flat" | |
| raise KeyError( | |
| f"Probe {probe.get('probe_id', '?')} has no usable Twi text — " | |
| f"expected one of: prompt_twi_validated, prompt_twi_draft, twi_prompt" | |
| ) | |
| def translation_correction_rate(bilingual_probes: list[dict]) -> dict: | |
| """ | |
| Per §1: measure how often human validators needed to correct the | |
| machine-translated draft. This is itself a methodology data point for | |
| the submission ("X% of GhanaNLP/Khaya drafts required human correction"). | |
| Only meaningful for probes that HAVE both a draft and a validated | |
| field recorded (new schema) — legacy flat-field probes are excluded | |
| from the denominator since no draft/correction distinction exists for them. | |
| Returns: | |
| { | |
| "total_with_draft_and_validation": int, | |
| "corrected_count": int, # validated != draft | |
| "unchanged_count": int, # validated == draft | |
| "correction_rate_pct": float, | |
| "still_unreviewed_count": int, # has draft, no validated yet | |
| } | |
| """ | |
| total_dual = 0 | |
| corrected = 0 | |
| unchanged = 0 | |
| unreviewed = 0 | |
| for p in bilingual_probes: | |
| draft = p.get("prompt_twi_draft") | |
| validated = p.get("prompt_twi_validated") | |
| if draft is None: | |
| continue # legacy schema, no draft recorded — not comparable | |
| if validated is None: | |
| unreviewed += 1 | |
| continue | |
| total_dual += 1 | |
| if validated.strip() != draft.strip(): | |
| corrected += 1 | |
| else: | |
| unchanged += 1 | |
| rate = round(corrected / total_dual * 100, 2) if total_dual else 0.0 | |
| summary = { | |
| "total_with_draft_and_validation": total_dual, | |
| "corrected_count": corrected, | |
| "unchanged_count": unchanged, | |
| "correction_rate_pct": rate, | |
| "still_unreviewed_count": unreviewed, | |
| } | |
| logger.info( | |
| f"Translation correction rate: {rate}% " | |
| f"({corrected}/{total_dual} drafts corrected, " | |
| f"{unreviewed} still unreviewed)" | |
| ) | |
| return summary | |
| def filter_by_domain(probes: list[dict], domain: str) -> list[dict]: | |
| """Return only probes matching the given disease domain.""" | |
| return [p for p in probes if p.get("disease_domain") == domain] | |
| def filter_by_category(probes: list[dict], category: str) -> list[dict]: | |
| """Return only probes matching the given failure category.""" | |
| return [p for p in probes if p.get("failure_category") == category] | |
| def filter_approved(probes: list[dict]) -> list[dict]: | |
| """Return only probes with validation_status == 'Approved'.""" | |
| return [p for p in probes if p.get("validation_status") == "Approved"] | |
| def build_pilot_set(probes: list[dict], per_domain: int = 5) -> list[dict]: | |
| """ | |
| Build a balanced pilot set with `per_domain` probes from each disease domain. | |
| Args: | |
| probes : full probe list | |
| per_domain : how many probes to take from each domain | |
| Returns: | |
| Balanced pilot list. | |
| """ | |
| domain_counts: dict[str, int] = {} | |
| pilot: list[dict] = [] | |
| for probe in probes: | |
| domain = probe.get("disease_domain", "") | |
| if domain_counts.get(domain, 0) < per_domain: | |
| pilot.append(probe) | |
| domain_counts[domain] = domain_counts.get(domain, 0) + 1 | |
| logger.info( | |
| f"Built pilot set: {len(pilot)} probes " | |
| f"({per_domain}/domain across {len(domain_counts)} domains)" | |
| ) | |
| return pilot | |