gmass-demo / probes /loader.py
kofi-scholar's picture
Make Space deployment self-contained
75b4f2e
Raw
History Blame Contribute Delete
10.4 kB
"""
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