CoolFace
Apppublic

Dhrona1421/multimodal-content-moderation

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
inference_eval.py468 linesDownload Raw Back to root
1"""2inference.py — Advanced inference with chain-of-thought LLM agent and3               deterministic rule-based baseline.4 5LLM agent features:6  • Multi-step chain-of-thought reasoning (image → user → text → decision)7  • 3-shot examples covering easy/medium/hard cases8  • Structured JSON output: action + confidence + reasoning chain9  • Retry with exponential backoff on API errors10  • Conversation-style context window (last 2 steps retained)11 12Rule-based agent features:13  • Weighted keyword scoring with per-group thresholds14  • Cross-modal conflict detection (safe text + harmful image)15  • Repeat-offender escalation via session history16  • Confidence calibrated to signal strength17 18Both agents support the (action, confidence, reasoning) -> Tuple return signature19required by the grader for ECE scoring.20"""21 22from __future__ import annotations23 24import json25import os26import re27import sys28import time29from typing import Any, Dict, List, Optional, Tuple30 31from features import (32    FEATURE_DIM, ACTIONS, IMAGE_TAGS, USER_TYPES,33    KEYWORD_GROUPS, BIGRAM_SIGNALS,34)35from grader import ModerationGrader36 37for stream_name in ("stdout", "stderr"):38    stream = getattr(sys, stream_name, None)39    if hasattr(stream, "reconfigure"):40        stream.reconfigure(encoding="utf-8")41 42# ── Configuration ─────────────────────────────────────────────────────────────43 44MODEL_NAME   = os.environ.get("MODEL_NAME", "gpt-4o-mini")45API_BASE_URL = os.environ.get("API_BASE_URL", "https://api.openai.com/v1")46HF_TOKEN     = os.environ.get("HF_TOKEN", os.environ.get("OPENAI_API_KEY", ""))47 48CONFIDENCE_THRESHOLD = 0.45   # below → escalate49 50# ── LLM client ───────────────────────────────────────────────────────────────51 52try:53    from openai import OpenAI  # type: ignore54    _client = OpenAI(api_key=HF_TOKEN, base_url=API_BASE_URL) if HF_TOKEN else None55    LLM_AVAILABLE = _client is not None56except ImportError:57    _client = None58    LLM_AVAILABLE = False59 60# ─────────────────────────────────────────────────────────────────────────────61# Chain-of-thought system prompt62# ─────────────────────────────────────────────────────────────────────────────63 64_FEW_SHOT = """65--- EXAMPLE 1 (EASY — remove) ---66Post: "DM me for Oxy, Xanax, no script needed 🔥 Discreet shipping worldwide"67Image: drugs | User: suspicious | History: 068Reasoning:69  1. IMAGE: drugs tag confirms pharmaceutical content70  2. USER:  suspicious account with no session context71  3. TEXT:  names controlled substances, explicit "no script" = illegal sale72  4. CROSS: all signals align — no conflict73Decision: remove (confidence 0.97)74 75--- EXAMPLE 2 (MEDIUM — flag) ---76Post: "Just saying, crime rates went way up after THOSE people moved in. Funny how that works 👀"77Image: safe | User: new78Reasoning:79  1. IMAGE: safe — no direct visual harm80  2. USER:  new account, no history81  3. TEXT:  "those people" + crime correlation = coded racist dog-whistle, no explicit slur82  4. CROSS: text harm, image safe — moderate severity83Decision: flag (confidence 0.78)84 85--- EXAMPLE 3 (HARD — allow) ---86Post: "As an ICU nurse, knowing acetaminophen toxicity thresholds can save lives. Always check labels 🏥"87Image: drugs | User: trusted88Reasoning:89  1. IMAGE: drugs tag — pills in photo90  2. USER:  trusted, no session flags91  3. TEXT:  professional medical harm-reduction context, no instructional misuse92  4. CROSS: drugs image + trusted + educational context → net safe93Decision: allow (confidence 0.82)94""".strip()95 96SYSTEM_PROMPT = f"""You are a senior content moderator at a major social media platform.97 98You will receive a social media post with:99  text          — caption or body text100  image_tag     — safe | nudity | violence | drugs | misleading101  user_type     — new | trusted | suspicious102  user_history  — weighted recency-decayed session flag count (0–5)103  step          — current position in the moderation queue104 105YOUR TASK: decide one action and rate your confidence.106 107ACTIONS:108  allow  — safe content, publish immediately109  flag   — borderline, route to human review queue110  remove — clear violation, immediate takedown111 112CHAIN-OF-THOUGHT PROTOCOL — you must evaluate ALL four signals:113  1. IMAGE:  Does the image tag alone warrant action?114  2. USER:   Does user trust level shift the decision?115  3. TEXT:   What does the text signal (keywords, tone, intent)?116  4. CROSS:  Do signals conflict? (safe text + harmful image → image wins)117 118CONFIDENCE CALIBRATION:119  ≥ 0.85  = very certain (clear violation or clear safe)120  0.60–0.84 = confident with minor ambiguity121  0.45–0.59 = uncertain, leaning one way122  < 0.45  = genuinely unsure → triggers human escalation (partial credit)123 124MODERATION RULES:125  • Harmful image overrides safe-looking text126  • Trusted users get moderate benefit of the doubt — NOT for serious violations127  • Suspicious user with history ≥ 2 warrants extra scrutiny128  • Medical/educational context with drugs/violence can be allow129  • Satire/sports framing does NOT automatically justify graphic content130  • When torn between flag and remove → choose flag131 132EXAMPLES:133{_FEW_SHOT}134 135Respond ONLY with valid JSON (no markdown fences):136{{137  "reasoning": {{138    "image_signal":  "one sentence",139    "user_signal":   "one sentence",140    "text_signal":   "one sentence",141    "cross_modal":   "one sentence",142    "final_logic":   "one sentence"143  }},144  "action":     "allow|flag|remove",145  "confidence": 0.0-1.0146}}"""147 148 149# ─────────────────────────────────────────────────────────────────────────────150# Observation formatter151# ─────────────────────────────────────────────────────────────────────────────152 153def format_observation(obs: Dict[str, Any]) -> str:154    return (155        f"[Post #{obs.get('post_id','?')} | "156        f"Step {obs.get('step','?')}/{obs.get('max_steps','?')} | "157        f"Difficulty: {obs.get('difficulty','?').upper()}]\n\n"158        f"text         : {obs.get('text','')}\n"159        f"image_tag    : {obs.get('image_tag','safe')}\n"160        f"user_type    : {obs.get('user_type','new')}\n"161        f"user_history : {obs.get('user_history', 0):.2f}  "162        f"(weighted recency-decayed session flags)\n"163        f"step         : {obs.get('step',1)}/{obs.get('max_steps',12)}"164    )165 166 167# ─────────────────────────────────────────────────────────────────────────────168# LLM agent169# ─────────────────────────────────────────────────────────────────────────────170 171def _clean_json(text: str) -> str:172    """Extract and clean a JSON block from potentially messy LLM output."""173    # 1. Strip markdown code fences (```json or ```)174    text = re.sub(r"```(?:json)?", "", text)175    text = re.sub(r"```", "", text)176    # 2. Extract anything between the first { and the last }177    match = re.search(r"\{.*\}", text, re.DOTALL)178    if match:179        return match.group(0).strip()180    return text.strip()181 182 183def llm_agent(184    obs: Dict[str, Any],185    max_retries: int = 3,186) -> Tuple[str, float, Dict[str, str]]:187    """188    Chain-of-thought LLM agent. Falls back to rule_based_agent on error.189    """190    if not LLM_AVAILABLE:191        return rule_based_agent(obs)192 193    for attempt in range(max_retries):194        try:195            resp = _client.chat.completions.create(   # type: ignore[union-attr]196                model=MODEL_NAME,197                messages=[198                    {"role": "system", "content": SYSTEM_PROMPT},199                    {"role": "user",   "content": format_observation(obs)},200                ],201                temperature=0.0,202                max_tokens=450,203            )204            raw = resp.choices[0].message.content or ""205            clean = _clean_json(raw)206            parsed = json.loads(clean)207 208            action = str(parsed.get("action", "flag")).lower().strip()209            confidence = float(parsed.get("confidence", 0.75))210            reasoning = parsed.get("reasoning", {})211 212            if action not in ACTIONS:213                action = "flag"214            confidence = float(max(0.0, min(1.0, confidence)))215            return action, confidence, reasoning216 217        except Exception as exc:218            wait = 1 + (attempt * 2)219            print(f"  [LLM] Attempt {attempt+1} failed: {exc}. Retrying in {wait}s...",220                  file=sys.stderr)221            time.sleep(wait)222 223    a, c, r = rule_based_agent(obs)224    return a, c, r225 226 227# ─────────────────────────────────────────────────────────────────────────────228# Rule-based agent  (deterministic, no API)229# ─────────────────────────────────────────────────────────────────────────────230 231def _keyword_score(text: str, group: str) -> float:232    """Return fraction of group keywords present in text."""233    kws  = KEYWORD_GROUPS.get(group, [])234    hits = sum(1 for kw in kws if kw in text)235    return min(hits / max(len(kws) * 0.25, 1.0), 1.0)236 237 238def _phrase_hit(text: str, group: str) -> bool:239    return any(p in text for p in BIGRAM_SIGNALS.get(group, []))240 241 242def rule_based_agent(obs: Dict[str, Any]) -> Tuple[str, float, Dict[str, str]]:243    """244    Deterministic rule-based agent using weighted keyword scoring245    and cross-modal conflict resolution.246 247    Returns (action, confidence, reasoning_dict).248    """249    image   = obs.get("image_tag",    "safe")250    user    = obs.get("user_type",    "new")251    text    = obs.get("text",         "").lower()252    history = float(obs.get("user_history", 0))253 254    # ── Keyword scores ────────────────────────────────────────────────────────255    spam_s    = _keyword_score(text, "spam_scam")256    hate_s    = _keyword_score(text, "hate_speech")257    viol_s    = _keyword_score(text, "violence")258    drug_s    = _keyword_score(text, "drugs")259    misinfo_s = _keyword_score(text, "misleading")260    harm_s    = _keyword_score(text, "self_harm")261    fraud_s   = _keyword_score(text, "financial_fraud")262    safe_s    = _keyword_score(text, "safe_positive")263    edu_s     = _keyword_score(text, "professional_educational")264 265    # ── Phrase signals ────────────────────────────────────────────────────────266    credible_threat  = _phrase_hit(text, "credible_threat")267    coded_sales      = _phrase_hit(text, "coded_sales")268    authoritative    = _phrase_hit(text, "authoritative_deny")269    community_pos    = _phrase_hit(text, "community_positive")270 271    # ── Trust modifier ────────────────────────────────────────────────────────272    trust_factor = {"trusted": 0.7, "new": 1.0, "suspicious": 1.3}.get(user, 1.0)273    # history amplifier: each unit of decayed history adds 10% weight274    hist_factor  = 1.0 + min(history * 0.1, 0.5)275 276    # ─────────────────────────────────────────────────────────────────────────277    # Decision tree (ordered by severity)278    # ─────────────────────────────────────────────────────────────────────────279 280    # 1. Absolute removes — image-driven, regardless of text/user281    if image == "nudity":282        return "remove", 0.97, {"image_signal": "Nudity tag requires immediate takedown."}283 284    if credible_threat:285        return "remove", 0.95, {"text_signal": "Credible threat detected in caption."}286 287    if image == "drugs" and spam_s > 0.4 * trust_factor:288        conf = round(min(0.85 + spam_s * 0.1, 0.97), 3)289        return "remove", conf, {"cross_modal": "Drug image combined with pharmaceutical sales text."}290 291    if spam_s > 0.55 * trust_factor and user != "trusted":292        conf = round(min(0.82 + spam_s * 0.1, 0.97), 3)293        return "remove", conf, {"text_signal": "High spam score detected for non-trusted user."}294 295    if fraud_s > 0.5 and image in ("safe", "misleading"):296        act = "remove" if user == "suspicious" else "flag"297        return act, 0.85, {"text_signal": f"Financial fraud indicators ({fraud_s:.2f}) lead to {act}."}298 299    # 2. Remove on high harm text + suspicious300    if user == "suspicious" and hist_factor > 1.3:301        if hate_s > 0.3 or viol_s > 0.3:302            conf = round(min(0.72 + hist_factor * 0.05, 0.90), 3)303            return "remove", conf, {"user_signal": "Suspicious user with harmful text history."}304 305    # 3. Flag — image-driven306    if image == "misleading":307        if authoritative and edu_s > 0.2:308            return "allow", 0.75, {"final_logic": "Agent actively debunking misinformation."}309        conf = 0.80 if user != "trusted" else 0.72310        return "flag", round(conf * trust_factor, 3), {"image_signal": "Misleading image tag requires review."}311 312    if image == "violence":313        sports = ["boxing", "mma", "ufc", "wrestling", "match", "fight night",314                  "professional", "tournament"]315        if any(k in text for k in sports) and edu_s > 0.1:316            return "allow", 0.76, {"final_logic": "Content recognized as professional sports."}317        return "flag", round(0.79 * trust_factor, 3), {"image_signal": "Violent image tag requires review."}318 319    if image == "drugs":320        if edu_s > 0.3 and user == "trusted":321            return "allow", 0.80, {"final_logic": "Medical/educational drug content from trusted user."}322        return "flag", round(0.76 * trust_factor, 3), {"image_signal": "Drug image tag requires review."}323 324    # 4. Flag — text-driven325    weighted_hate = hate_s * trust_factor * hist_factor326    if weighted_hate > 0.25:327        conf = round(min(0.65 + weighted_hate * 0.2, 0.90), 3)328        return "flag", conf, {"text_signal": "Hate speech indicators above threshold."}329 330    if misinfo_s > 0.35:331        return "flag", round(0.74 * trust_factor, 3), {"text_signal": "Misinformation indicators detected."}332 333    if harm_s > 0.3:334        return "flag", 0.78, {"text_signal": "Self-harm indicators detected."}335 336    if drug_s > 0.4:337        return "flag", round(0.72 * trust_factor, 3), {"text_signal": "Drug-related keywords detected."}338 339    if coded_sales and user != "trusted":340        return "flag", round(0.68 * trust_factor * hist_factor, 3), {"text_signal": "Coded trafficking language detected."}341 342    # 5. Allow343    if image == "safe":344        if user == "trusted":345            conf = 0.93346        elif user == "new":347            conf = 0.82348        else:349            if hist_factor > 1.3:350                return "flag", 0.58, {"user_signal": "Suspicious user with high flag history."}351            conf = 0.70352        if safe_s > 0.4 or community_pos:353            conf = min(conf + 0.05, 0.97)354        return "allow", round(conf, 3), {"final_logic": "Standard safe content with no flags."}355 356    return "allow", 0.62, {"final_logic": "Default allow (no clear violations detected)."}357 358 359# ─────────────────────────────────────────────────────────────────────────────360# Full evaluation runner361# ─────────────────────────────────────────────────────────────────────────────362 363def run_inference(364    force_rule_based: bool          = False,365    dataset_path:     str           = "moderation_dataset.json",366    seed:             int           = 42,367    task_filter:      Optional[str] = None,368    verbose:          bool          = False,369    extra_agents:     Optional[Dict[str, Any]] = None,370) -> Dict[str, Any]:371    """372    Run full evaluation and return grading reports for all agents.373 374    Args:375        force_rule_based: Skip LLM, run only rule-based.376        dataset_path:     JSON dataset path.377        seed:             RNG seed.378        task_filter:      Grade only this task ('easy'/'medium'/'hard').379        verbose:          Step-by-step breakdown.380        extra_agents:     Optional dict {name: agent_fn} for comparison.381    """382    agents: Dict[str, Any] = {}383 384    if not force_rule_based and LLM_AVAILABLE:385        # Wrap llm_agent to only return (action, confidence) for the grader386        agents[f"LLM ({MODEL_NAME})"] = lambda obs: llm_agent(obs)[:2]387    388    # Wrap rule_based_agent to only return (action, confidence) for the grader389    agents["Rule-Based"] = lambda obs: rule_based_agent(obs)[:2]390 391    if extra_agents:392        agents.update(extra_agents)393 394    grader  = ModerationGrader(dataset_path=dataset_path, seed=seed)395    reports: Dict[str, Any] = {}396 397    print(f"\n{'═'*78}")398    print(f"  Content Moderation Environment — Inference & Evaluation")399    print(f"  Agents: {', '.join(agents.keys())}   Seed: {seed}")400    print(f"{'═'*78}\n")401 402    for agent_name, agent_fn in agents.items():403        print(f"  Running: {agent_name}")404        if task_filter:405            result = grader.grade_single_task(task_filter, agent_fn)406            report = {407                "aggregate_score": result["score"],408                "tasks": {task_filter: result},409                "summary": grader._build_summary({task_filter: result}, result["score"]),410            }411        else:412            report = grader.grade_all_tasks(agent_fn)413 414        reports[agent_name] = report415        grader.print_report(report, verbose=verbose)416 417    # ── Comparison table ──────────────────────────────────────────────────────418    if len(agents) > 1:419        print(f"\n{'═'*78}")420        print(f"  AGENT COMPARISON")421        print(f"{'─'*78}")422        tasks_shown = list(reports[list(agents.keys())[0]]["tasks"].keys())423        header = f"  {'Agent':<25}" + "".join(f" {t.upper():>10}" for t in tasks_shown) + "  AGGREGATE"424        print(header)425        print(f"{'─'*78}")426        for name, rpt in reports.items():427            row = f"  {name:<25}"428            for t in tasks_shown:429                row += f" {rpt['tasks'][t]['score']:>10.4f}"430            row += f"  {rpt['aggregate_score']:>9.4f}"431            print(row)432        print(f"{'═'*78}\n")433 434    # Save results435    slim: Dict[str, Any] = {436        "seed": seed,437        "agents": {438            name: {439                "aggregate_score": rpt["aggregate_score"],440                "tasks": {441                    t: {k: v for k, v in d.items() if k not in ("step_results",)}442                    for t, d in rpt["tasks"].items()443                },444            }445            for name, rpt in reports.items()446        },447    }448    with open("results.json", "w", encoding="utf-8") as fh:449        json.dump(slim, fh, indent=2)450    print("  [Results saved → results.json]\n")451 452    return reports453 454 455# ─────────────────────────────────────────────────────────────────────────────456# CLI457# ─────────────────────────────────────────────────────────────────────────────458 459if __name__ == "__main__":460    args = sys.argv[1:]461    seed = int(args[args.index("--seed") + 1]) if "--seed" in args else 42462    reports = run_inference(463        force_rule_based="--rule-based" in args,464        verbose="--verbose" in args,465        seed=seed,466        task_filter=(args[args.index("--task") + 1] if "--task" in args else None),467    )468