microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/1637-l4-tests

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

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
6Reads JSON-lines input containing text records, classifies each via Detoxify,
7and writes structured JSON output with per-record scores and an overall summary.
8Exits with code 1 when any record exceeds the toxicity threshold.
9"""
10
11import argparse
12import json
13import logging
14import sys
15from pathlib import Path
16from typing import Any, Literal
17
18EXIT_SUCCESS = 0
19EXIT_FAILURE = 1
20EXIT_ERROR = 2
21
22logger = logging.getLogger(__name__)
23
24
25def 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
70def 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
76def 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
106def 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
151def 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
166def 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
199if __name__ == "__main__":
200 sys.exit(main())
201