microsoft/hve-core
Publicmirrored from https://github.com/microsoft/hve-coreAvailable
scripts/evals/moderation/moderate.py
200lines · modecode
| 1 | #!/usr/bin/env python3 |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # SPDX-License-Identifier: MIT |
| 4 | """Content moderation CLI using Detoxify toxicity classifier. |
| 5 | |
| 6 | Reads JSON-lines input containing text records, classifies each via Detoxify, |
| 7 | and writes structured JSON output with per-record scores and an overall summary. |
| 8 | Exits with code 1 when any record exceeds the toxicity threshold. |
| 9 | """ |
| 10 | |
| 11 | import argparse |
| 12 | import json |
| 13 | import logging |
| 14 | import sys |
| 15 | from pathlib import Path |
| 16 | from typing import Any, Literal |
| 17 | |
| 18 | EXIT_SUCCESS = 0 |
| 19 | EXIT_FAILURE = 1 |
| 20 | EXIT_ERROR = 2 |
| 21 | |
| 22 | logger = logging.getLogger(__name__) |
| 23 | |
| 24 | |
| 25 | def create_parser() -> argparse.ArgumentParser: |
| 26 | """Create and configure argument parser.""" |
| 27 | parser = argparse.ArgumentParser( |
| 28 | description="Moderate text content using Detoxify toxicity classifier", |
| 29 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 30 | ) |
| 31 | input_group = parser.add_mutually_exclusive_group(required=True) |
| 32 | input_group.add_argument( |
| 33 | "--input", |
| 34 | type=Path, |
| 35 | help="Path to JSON-lines input file with {id, text} records", |
| 36 | ) |
| 37 | input_group.add_argument( |
| 38 | "--stdin", |
| 39 | action="store_true", |
| 40 | help="Read JSON-lines input from stdin", |
| 41 | ) |
| 42 | parser.add_argument( |
| 43 | "--threshold", |
| 44 | type=float, |
| 45 | default=0.5, |
| 46 | help="Toxicity threshold (0.0-1.0); scores above this trigger a flag (default: 0.5)", |
| 47 | ) |
| 48 | parser.add_argument( |
| 49 | "--model", |
| 50 | type=str, |
| 51 | choices=["original", "unbiased", "multilingual"], |
| 52 | default="unbiased", |
| 53 | help="Detoxify model variant (default: unbiased)", |
| 54 | ) |
| 55 | parser.add_argument( |
| 56 | "--output", |
| 57 | type=Path, |
| 58 | required=True, |
| 59 | help="Path to write structured JSON output", |
| 60 | ) |
| 61 | parser.add_argument( |
| 62 | "-v", |
| 63 | "--verbose", |
| 64 | action="store_true", |
| 65 | help="Enable verbose logging", |
| 66 | ) |
| 67 | return parser |
| 68 | |
| 69 | |
| 70 | def configure_logging(verbose: bool = False) -> None: |
| 71 | """Configure logging based on verbosity level.""" |
| 72 | level = logging.DEBUG if verbose else logging.INFO |
| 73 | logging.basicConfig(level=level, format="%(levelname)s: %(message)s") |
| 74 | |
| 75 | |
| 76 | def load_records(input_path: Path | None) -> list[dict[str, str]]: |
| 77 | """Load JSON-lines records from file or stdin.""" |
| 78 | records = [] |
| 79 | source = sys.stdin if input_path is None else input_path.open(encoding="utf-8") |
| 80 | try: |
| 81 | for line_num, line in enumerate(source, start=1): |
| 82 | line = line.strip() |
| 83 | if not line: |
| 84 | continue |
| 85 | try: |
| 86 | record = json.loads(line) |
| 87 | if not isinstance(record, dict): |
| 88 | logger.warning("Line %d: expected object, got %s", line_num, type(record).__name__) |
| 89 | continue |
| 90 | if "id" not in record or "text" not in record: |
| 91 | logger.warning("Line %d: missing required fields (id, text)", line_num) |
| 92 | continue |
| 93 | if not isinstance(record["text"], str): |
| 94 | logger.warning("Line %d: 'text' must be a string, got %s", line_num, type(record["text"]).__name__) |
| 95 | continue |
| 96 | records.append(record) |
| 97 | except json.JSONDecodeError as e: |
| 98 | logger.warning("Line %d: JSON parse error: %s", line_num, e) |
| 99 | finally: |
| 100 | if input_path is not None: |
| 101 | source.close() |
| 102 | logger.info("Loaded %d records", len(records)) |
| 103 | return records |
| 104 | |
| 105 | |
| 106 | def classify_records( |
| 107 | records: list[dict[str, str]], |
| 108 | model_name: Literal["original", "unbiased", "multilingual"], |
| 109 | threshold: float, |
| 110 | ) -> list[dict[str, Any]]: |
| 111 | """Classify records using Detoxify and return results with flag status.""" |
| 112 | try: |
| 113 | from detoxify import Detoxify |
| 114 | except ImportError as exc: |
| 115 | raise ImportError("detoxify package not installed; run: uv pip install -r requirements.txt") from exc |
| 116 | |
| 117 | logger.info("Loading Detoxify model: %s", model_name) |
| 118 | model = Detoxify(model_name) |
| 119 | |
| 120 | results = [] |
| 121 | for record in records: |
| 122 | record_id = record["id"] |
| 123 | text = record["text"] |
| 124 | logger.debug("Classifying record: %s", record_id) |
| 125 | |
| 126 | scores = model.predict(text) |
| 127 | # Convert numpy types to native Python floats |
| 128 | scores = {k: float(v) for k, v in scores.items()} |
| 129 | |
| 130 | flagged_labels = [label for label, score in scores.items() if score > threshold] |
| 131 | flagged = len(flagged_labels) > 0 |
| 132 | |
| 133 | results.append( |
| 134 | { |
| 135 | "id": record_id, |
| 136 | "scores": scores, |
| 137 | "flagged": flagged, |
| 138 | "flaggedLabels": flagged_labels, |
| 139 | } |
| 140 | ) |
| 141 | if flagged: |
| 142 | logger.warning( |
| 143 | "Record %s FLAGGED: %s", |
| 144 | record_id, |
| 145 | ", ".join(f"{label}={scores[label]:.3f}" for label in flagged_labels), |
| 146 | ) |
| 147 | |
| 148 | return results |
| 149 | |
| 150 | |
| 151 | def write_output(results: list[dict[str, Any]], output_path: Path) -> None: |
| 152 | """Write structured JSON output with per-record results and summary.""" |
| 153 | flagged_count = sum(1 for r in results if r["flagged"]) |
| 154 | output = { |
| 155 | "records": results, |
| 156 | "summary": { |
| 157 | "total": len(results), |
| 158 | "flaggedCount": flagged_count, |
| 159 | }, |
| 160 | } |
| 161 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 162 | output_path.write_text(json.dumps(output, indent=2), encoding="utf-8") |
| 163 | logger.info("Wrote output to %s", output_path) |
| 164 | |
| 165 | |
| 166 | def main() -> int: |
| 167 | """Main entry point.""" |
| 168 | parser = create_parser() |
| 169 | args = parser.parse_args() |
| 170 | configure_logging(args.verbose) |
| 171 | |
| 172 | if args.threshold < 0.0 or args.threshold > 1.0: |
| 173 | logger.error("Threshold must be between 0.0 and 1.0") |
| 174 | return EXIT_ERROR |
| 175 | |
| 176 | input_path = args.input |
| 177 | records = load_records(input_path) |
| 178 | if not records: |
| 179 | logger.warning("No records to process") |
| 180 | write_output([], args.output) |
| 181 | return EXIT_SUCCESS |
| 182 | |
| 183 | try: |
| 184 | results = classify_records(records, args.model, args.threshold) |
| 185 | except ImportError as exc: |
| 186 | logger.error("%s", exc) |
| 187 | return EXIT_ERROR |
| 188 | write_output(results, args.output) |
| 189 | |
| 190 | flagged_count = sum(1 for r in results if r["flagged"]) |
| 191 | if flagged_count > 0: |
| 192 | logger.error("Content moderation failed: %d/%d records flagged", flagged_count, len(results)) |
| 193 | return EXIT_FAILURE |
| 194 | |
| 195 | logger.info("Content moderation passed: all %d records clean", len(results)) |
| 196 | return EXIT_SUCCESS |
| 197 | |
| 198 | |
| 199 | if __name__ == "__main__": |
| 200 | sys.exit(main()) |
| 201 | |