Text Classification
Transformers
ONNX
Safetensors
multilingual
xlm-roberta
privacy
pii-detection
text-embeddings-inference
File size: 4,980 Bytes
a7857c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
#!/usr/bin/env python3
"""Standalone inference for the internal three-label PII classifier."""

from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import Any

import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer


LABELS = (
    "privacy_asking_for_pii",
    "privacy_giving_pii",
    "directing_users_off_platform",
)
INSTRUCTION_PREFIX = (
    "Instruct: In the following chat messages from target speaker t and possibly "
    "other speakers s1, s2, etc., detect abuse by speaker t.\nQuery:"
)
INSTRUCTION_SEPARATOR = "\n\n"
TURN_SEPARATOR = " </s> "
TARGET_SPEAKER = "t"
SPEAKER_TEXT_SEPARATOR = ": "
MAX_LENGTH = 512


def _validate_turn(turn: Any, index: int) -> tuple[str, str]:
    if not isinstance(turn, dict):
        raise TypeError(f"conversation turn {index} must be a JSON object")
    if set(turn) != {"speaker", "text"}:
        raise ValueError(
            f"conversation turn {index} must contain exactly 'speaker' and 'text'"
        )
    speaker = turn["speaker"]
    text = turn["text"]
    if not isinstance(speaker, str) or not speaker:
        raise ValueError(f"conversation turn {index} has an invalid speaker")
    if not isinstance(text, str):
        raise ValueError(f"conversation turn {index} has non-string text")
    return speaker, text


def format_conversation(value: str | list[dict[str, str]]) -> str:
    """Format plain text or speaker/text turns exactly like the training formatter."""
    if isinstance(value, str):
        turns: list[dict[str, str]] = [{"speaker": TARGET_SPEAKER, "text": value}]
    elif isinstance(value, list):
        if not value:
            raise ValueError("conversation must contain at least one turn")
        turns = value
    else:
        raise TypeError("input must be plain text or a JSON-list conversation")

    other_speakers: dict[str, str] = {}
    formatted_turns: list[str] = []
    for index, turn in enumerate(turns):
        speaker, text = _validate_turn(turn, index)
        if speaker == TARGET_SPEAKER:
            anonymous_speaker = TARGET_SPEAKER
        else:
            if speaker not in other_speakers:
                other_speakers[speaker] = f"s{len(other_speakers) + 1}"
            anonymous_speaker = other_speakers[speaker]
        formatted_turns.append(
            f"{anonymous_speaker}{SPEAKER_TEXT_SEPARATOR}{text}"
        )

    return (
        INSTRUCTION_PREFIX
        + INSTRUCTION_SEPARATOR
        + TURN_SEPARATOR.join(formatted_turns)
    )


def parse_input(value: str) -> str | list[dict[str, str]]:
    """Interpret a JSON list as turns; otherwise retain the value as plain text."""
    try:
        decoded = json.loads(value)
    except json.JSONDecodeError:
        return value
    if isinstance(decoded, list):
        return decoded
    return value


def predict(
    value: str | list[dict[str, str]],
    model_path: str | Path,
) -> dict[str, float]:
    """Return uncalibrated sigmoid probabilities in the fixed three-label order."""
    formatted = format_conversation(value)
    tokenizer = AutoTokenizer.from_pretrained(model_path)
    tokenizer.truncation_side = "left"
    model = AutoModelForSequenceClassification.from_pretrained(model_path)
    model.eval()

    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    model.to(device)
    encoded = tokenizer(
        formatted,
        padding="max_length",
        max_length=MAX_LENGTH,
        truncation=True,
        return_tensors="pt",
    )
    encoded = {name: tensor.to(device) for name, tensor in encoded.items()}

    with torch.inference_mode():
        logits = model(**encoded).logits
        if logits.ndim != 2 or logits.shape[0] != 1 or logits.shape[1] != len(LABELS):
            raise ValueError(
                f"expected logits shape (1, {len(LABELS)}), got {tuple(logits.shape)}"
            )
        probabilities = torch.sigmoid(logits[0]).float().cpu().tolist()
    return {label: probability for label, probability in zip(LABELS, probabilities)}


def _arguments() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Run the internal three-label PII classifier."
    )
    inputs = parser.add_mutually_exclusive_group(required=True)
    inputs.add_argument("--text", help="Plain text or a JSON-list conversation.")
    inputs.add_argument(
        "--input-file",
        type=Path,
        help="UTF-8 file containing plain text or a JSON-list conversation.",
    )
    parser.add_argument("--model-path", type=Path, default=Path(__file__).parent)
    return parser.parse_args()


def main() -> None:
    args = _arguments()
    raw_value = (
        args.text
        if args.text is not None
        else args.input_file.read_text(encoding="utf-8")
    )
    result = predict(parse_input(raw_value), args.model_path)
    print(json.dumps(result, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    main()