gender-sensitization-engine / api /rules_engine.py
AcharO's picture
feat(api): Hausa and Zulu rewrite rules, lexicons, and aibridge gate wiring
b529688
Raw History Blame Contribute Delete
6.02 kB
"""Rules engine — applies corrections, builds edit/reason output. Uses core for rules and context."""
from __future__ import annotations
import re
from pathlib import Path
from core.context_checker import should_apply_correction
from core.rules_loader import load_rules as _load_rules
BASE = Path(__file__).resolve().parent.parent
RULES_DIR = BASE / "rules"
# Module-level cache — loaded once at import time
RULES: dict[str, list[dict]] = {
"en": _load_rules("en", RULES_DIR),
"sw": _load_rules("sw", RULES_DIR),
"fr": _load_rules("fr", RULES_DIR),
"ki": _load_rules("ki", RULES_DIR),
"ha": _load_rules("ha", RULES_DIR),
"zu": _load_rules("zu", RULES_DIR),
}
def _preserve_case(orig: str, replacement: str) -> str:
if not orig:
return replacement
if orig.isupper():
return replacement.upper()
if orig[0].isupper():
return replacement.capitalize()
return replacement
def _make_edit(orig: str, replacement: str, rule: dict) -> dict:
tags = rule.get("tags", "") or "occupation/role"
severity = rule.get("severity", "replace")
bias_type = rule.get("bias_label", "stereotype")
stereotype_category = rule.get("stereotype_category", "profession")
return {
"from": orig,
"to": replacement,
"severity": severity,
"tags": tags,
"bias_type": bias_type,
"stereotype_category": stereotype_category,
"reason": f"'{orig}' is gender-biased ({tags}); use gender-neutral '{replacement}'",
}
def _apply_rule(text: str, rule: dict) -> tuple[str, dict | None]:
"""Apply one rule to text. Returns (new_text, edit) or (text, None) if no match."""
biased = rule["biased"]
neutral = rule["neutral_primary"]
severity = rule.get("severity", "replace")
pattern = r"\b" + re.escape(biased) + r"\b"
if not re.search(pattern, text, flags=re.IGNORECASE):
return text, None
orig = re.search(pattern, text, flags=re.IGNORECASE).group(0)
replacement = _preserve_case(orig, neutral)
if severity == "warn":
new_text = text # warn = informational only; never mutate the rewritten text
else:
new_text = re.sub(
pattern,
lambda m: _preserve_case(m.group(0), neutral),
text,
flags=re.IGNORECASE,
)
# Empty replacements (e.g. Zulu gender suffix removal) can leave double spaces; do not strip full text.
new_text = re.sub(r" {2,}", " ", new_text)
return new_text, _make_edit(orig, replacement, rule)
def apply_rules_on_spans(
text: str, lang: str, flags: list = None
) -> tuple[str, list, int, list]:
"""
Apply lexicon rules to text with context checking.
Returns: (rewritten_text, edits, matched_count, skipped_context)
"""
edits = []
skipped = []
new_text = text
rules = RULES.get(lang, [])
if not rules:
return new_text, edits, 0, skipped
if flags:
matched = 0
for f in flags:
if "text" in f:
span_text = f["text"]
elif "span" in f:
s, e = f["span"]
span_text = text[s:e]
else:
continue
for rule in rules:
biased = rule["biased"]
pattern = r"\b" + re.escape(biased) + r"\b"
if not re.search(pattern, span_text, flags=re.IGNORECASE):
continue
avoid_when = rule.get("avoid_when", "")
constraints = rule.get("constraints", "")
if avoid_when or constraints:
ok, ctx_reason = should_apply_correction(text, biased, avoid_when, constraints)
if not ok:
skipped.append({"term": biased, "reason": ctx_reason, "avoid_when": avoid_when})
continue
new_text, edit = _apply_rule(new_text, rule)
if edit:
edits.append(edit)
matched += 1
break
return new_text, edits, matched, skipped
for rule in rules:
biased = rule["biased"]
pattern = r"\b" + re.escape(biased) + r"\b"
if not re.search(pattern, new_text, flags=re.IGNORECASE):
continue
avoid_when = rule.get("avoid_when", "")
constraints = rule.get("constraints", "")
if avoid_when or constraints:
ok, ctx_reason = should_apply_correction(text, biased, avoid_when, constraints)
if not ok:
skipped.append({"term": biased, "reason": ctx_reason, "avoid_when": avoid_when})
continue
new_text, edit = _apply_rule(new_text, rule)
if edit:
edits.append(edit)
return new_text, edits, len(edits), skipped
def build_reason(source: str, edits: list, skipped: list, aibridge_detected: bool = False) -> str:
if source == "aibridge_preserved":
return "No gender bias detected by external classifier. Text returned unchanged."
if source == "preserved":
if aibridge_detected:
return "Bias detected by external classifier but correction suppressed — rewrite would alter meaning (semantic score below threshold)."
return "Original preserved — correction would damage meaning (semantic score below threshold)."
if source == "ml":
return "No lexicon rules matched; ML fallback applied. Human review required."
if edits:
named = [e for e in edits if e.get('from', '').strip()]
terms = ", ".join(f"'{e['from']}'" for e in named)
count = len(named) if named else len(edits)
return f"{count} biased term(s) corrected: {terms}." if terms else f"{count} biased term(s) corrected."
if skipped:
terms = ", ".join(f"'{s['term']}'" for s in skipped)
return f"Bias terms detected ({terms}) but skipped — biographical, quote, or statistical context."
return "No gender bias detected."