narrative-harm-classifier 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- narrative_harm_classifier/__init__.py +29 -0
- narrative_harm_classifier/api/__init__.py +0 -0
- narrative_harm_classifier/api/main.py +50 -0
- narrative_harm_classifier/api/routes/__init__.py +0 -0
- narrative_harm_classifier/api/routes/benchmark.py +32 -0
- narrative_harm_classifier/api/routes/classify.py +59 -0
- narrative_harm_classifier/api/routes/health.py +23 -0
- narrative_harm_classifier/api/routes/tracking.py +81 -0
- narrative_harm_classifier/api/routes/validate.py +61 -0
- narrative_harm_classifier/classifier/__init__.py +0 -0
- narrative_harm_classifier/classifier/counter_narrative.py +65 -0
- narrative_harm_classifier/classifier/factory.py +61 -0
- narrative_harm_classifier/classifier/provenance.py +51 -0
- narrative_harm_classifier/classifier/rules/__init__.py +0 -0
- narrative_harm_classifier/classifier/rules/azure_nlp.py +137 -0
- narrative_harm_classifier/classifier/rules/dogwhistles.py +68 -0
- narrative_harm_classifier/classifier/rules/engine.py +360 -0
- narrative_harm_classifier/classifier/rules/patterns_loader.py +87 -0
- narrative_harm_classifier/classifier/taxonomy/__init__.py +0 -0
- narrative_harm_classifier/classifier/taxonomy/loader.py +125 -0
- narrative_harm_classifier/classifier/tracking/__init__.py +0 -0
- narrative_harm_classifier/classifier/tracking/models.py +102 -0
- narrative_harm_classifier/classifier/tracking/store.py +126 -0
- narrative_harm_classifier/classifier/tracking/tracker.py +155 -0
- narrative_harm_classifier/classifier/validators/__init__.py +0 -0
- narrative_harm_classifier/classifier/validators/benchmark.py +221 -0
- narrative_harm_classifier/classifier/validators/i18n_smoke.py +62 -0
- narrative_harm_classifier/classifier/validators/performance.py +132 -0
- narrative_harm_classifier/cli.py +180 -0
- narrative_harm_classifier/core/__init__.py +0 -0
- narrative_harm_classifier/core/config.py +60 -0
- narrative_harm_classifier/core/models.py +134 -0
- narrative_harm_classifier/core/yaml_loader.py +19 -0
- narrative_harm_classifier/data/benchmark_templates.yaml +336 -0
- narrative_harm_classifier/data/dogwhistles.yaml +134 -0
- narrative_harm_classifier/data/i18n_smoke_tests.yaml +82 -0
- narrative_harm_classifier/data/patterns/ar.yaml +97 -0
- narrative_harm_classifier/data/patterns/en.yaml +133 -0
- narrative_harm_classifier/data/patterns/es.yaml +99 -0
- narrative_harm_classifier/data/patterns/fr.yaml +96 -0
- narrative_harm_classifier/data/patterns/ha.yaml +40 -0
- narrative_harm_classifier/data/patterns/ig.yaml +40 -0
- narrative_harm_classifier/data/patterns/ru.yaml +89 -0
- narrative_harm_classifier/data/patterns/yo.yaml +40 -0
- narrative_harm_classifier/data/taxonomy_v1.yaml +115 -0
- narrative_harm_classifier-0.1.0.dist-info/METADATA +825 -0
- narrative_harm_classifier-0.1.0.dist-info/RECORD +50 -0
- narrative_harm_classifier-0.1.0.dist-info/WHEEL +4 -0
- narrative_harm_classifier-0.1.0.dist-info/entry_points.txt +2 -0
- narrative_harm_classifier-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""narrative_harm_classifier — narrative harm classification, escalation tracking, and benchmarking."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from narrative_harm_classifier.core.config import get_settings
|
|
6
|
+
from narrative_harm_classifier.core.models import ClassificationResult, ClassifyRequest
|
|
7
|
+
from narrative_harm_classifier.classifier.rules.engine import ClassificationEngine
|
|
8
|
+
from narrative_harm_classifier.classifier.rules.azure_nlp import AzureNLPClient
|
|
9
|
+
from narrative_harm_classifier.classifier.taxonomy.loader import load_taxonomy
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"__version__",
|
|
13
|
+
"classify",
|
|
14
|
+
"ClassificationResult",
|
|
15
|
+
"ClassifyRequest",
|
|
16
|
+
"ClassificationEngine",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def classify(text: str, context: str | None = None) -> ClassificationResult:
|
|
21
|
+
"""Convenience one-shot classification using the default taxonomy and settings."""
|
|
22
|
+
settings = get_settings()
|
|
23
|
+
taxonomy = load_taxonomy(settings.taxonomy_config_path)
|
|
24
|
+
azure_client = AzureNLPClient(
|
|
25
|
+
endpoint=settings.azure_text_analytics_endpoint,
|
|
26
|
+
key=settings.azure_text_analytics_key,
|
|
27
|
+
)
|
|
28
|
+
engine = ClassificationEngine(taxonomy=taxonomy, azure_client=azure_client)
|
|
29
|
+
return engine.classify(ClassifyRequest(text=text, context=context))
|
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
api/main.py — FastAPI application entry point.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from narrative_harm_classifier.core.config import get_settings
|
|
10
|
+
from narrative_harm_classifier.api.routes import classify, validate, health, tracking, benchmark
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@asynccontextmanager
|
|
16
|
+
async def lifespan(app: FastAPI):
|
|
17
|
+
settings = get_settings()
|
|
18
|
+
logger.info(f"Starting {settings.app_name} v{settings.app_version}")
|
|
19
|
+
logger.info(f"Taxonomy: {settings.taxonomy_config_path} v{settings.taxonomy_version}")
|
|
20
|
+
yield
|
|
21
|
+
logger.info("Shutting down")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_app() -> FastAPI:
|
|
25
|
+
settings = get_settings()
|
|
26
|
+
|
|
27
|
+
app = FastAPI(
|
|
28
|
+
title=settings.app_name,
|
|
29
|
+
version=settings.app_version,
|
|
30
|
+
description=(
|
|
31
|
+
"Narrative Harm Classification API\n\n"
|
|
32
|
+
"Implements the D2.4a classification specification with multi-dimensional "
|
|
33
|
+
"rule-based logic across targets, identities, harm mechanisms, and "
|
|
34
|
+
"decision thresholds. Azure Text Analytics integrated for optional NLP signal "
|
|
35
|
+
"amplification. Also provides escalation-chain tracking across sources over time "
|
|
36
|
+
"and a templated benchmark suite for credible precision/recall reporting."
|
|
37
|
+
),
|
|
38
|
+
lifespan=lifespan,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
app.include_router(health.router, tags=["Health"])
|
|
42
|
+
app.include_router(classify.router, prefix="/classify", tags=["Classification"])
|
|
43
|
+
app.include_router(validate.router, prefix="/validate", tags=["Validation"])
|
|
44
|
+
app.include_router(tracking.router, prefix="/tracking", tags=["Escalation Tracking"])
|
|
45
|
+
app.include_router(benchmark.router, prefix="/benchmark", tags=["Benchmark"])
|
|
46
|
+
|
|
47
|
+
return app
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
app = create_app()
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
api/routes/benchmark.py — Templated benchmark endpoint.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
6
|
+
|
|
7
|
+
from narrative_harm_classifier.core.config import get_settings, Settings
|
|
8
|
+
from narrative_harm_classifier.classifier.factory import build_benchmark_runner
|
|
9
|
+
from narrative_harm_classifier.classifier.validators.benchmark import BenchmarkRunner, BenchmarkReport
|
|
10
|
+
|
|
11
|
+
router = APIRouter()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_runner(settings: Settings = Depends(get_settings)) -> BenchmarkRunner:
|
|
15
|
+
return build_benchmark_runner(settings)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.post(
|
|
19
|
+
"/run",
|
|
20
|
+
response_model=BenchmarkReport,
|
|
21
|
+
summary="Run the templated functional-test benchmark suite",
|
|
22
|
+
description=(
|
|
23
|
+
"Generates and runs a HateCheck-style templated test suite (explicit/implicit "
|
|
24
|
+
"positives, negation, counter-speech, obfuscated spelling, cross-group consistency, "
|
|
25
|
+
"and benign hard negatives). Returns aggregate and per-test-type precision/recall/FPR."
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
def run_benchmark(runner: BenchmarkRunner = Depends(get_runner)) -> BenchmarkReport:
|
|
29
|
+
try:
|
|
30
|
+
return runner.run()
|
|
31
|
+
except Exception as e:
|
|
32
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
api/routes/classify.py — Classification endpoints.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
6
|
+
from narrative_harm_classifier.core.models import ClassifyRequest, ClassificationResult, BatchClassifyRequest, BatchClassificationResult
|
|
7
|
+
from narrative_harm_classifier.core.config import get_settings, Settings
|
|
8
|
+
from narrative_harm_classifier.classifier.taxonomy.loader import load_taxonomy
|
|
9
|
+
from narrative_harm_classifier.classifier.rules.engine import ClassificationEngine
|
|
10
|
+
from narrative_harm_classifier.classifier.factory import build_engine
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
router = APIRouter()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_engine(settings: Settings = Depends(get_settings)) -> ClassificationEngine:
|
|
17
|
+
return build_engine(settings)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.post(
|
|
21
|
+
"/",
|
|
22
|
+
response_model=ClassificationResult,
|
|
23
|
+
summary="Classify a single text item",
|
|
24
|
+
description=(
|
|
25
|
+
"Runs multi-dimensional classification against the active taxonomy. "
|
|
26
|
+
"Returns harm category, confidence, matched signals, and decision rationale."
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
def classify_text(
|
|
30
|
+
request: ClassifyRequest,
|
|
31
|
+
engine: ClassificationEngine = Depends(get_engine),
|
|
32
|
+
) -> ClassificationResult:
|
|
33
|
+
try:
|
|
34
|
+
return engine.classify(request)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.post(
|
|
40
|
+
"/batch",
|
|
41
|
+
response_model=BatchClassificationResult,
|
|
42
|
+
summary="Classify a batch of text items (max 100)",
|
|
43
|
+
)
|
|
44
|
+
def classify_batch(
|
|
45
|
+
request: BatchClassifyRequest,
|
|
46
|
+
engine: ClassificationEngine = Depends(get_engine),
|
|
47
|
+
) -> BatchClassificationResult:
|
|
48
|
+
results = [engine.classify(item) for item in request.items]
|
|
49
|
+
harmful = sum(1 for r in results if r.is_harmful)
|
|
50
|
+
settings = get_settings()
|
|
51
|
+
taxonomy = load_taxonomy(settings.taxonomy_config_path)
|
|
52
|
+
|
|
53
|
+
return BatchClassificationResult(
|
|
54
|
+
results=results,
|
|
55
|
+
total=len(results),
|
|
56
|
+
harmful_count=harmful,
|
|
57
|
+
taxonomy_version=taxonomy.version,
|
|
58
|
+
processed_at=datetime.utcnow(),
|
|
59
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
api/routes/health.py
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends
|
|
6
|
+
from narrative_harm_classifier.core.config import get_settings, Settings
|
|
7
|
+
from narrative_harm_classifier.classifier.taxonomy.loader import load_taxonomy
|
|
8
|
+
|
|
9
|
+
router = APIRouter()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("/health")
|
|
13
|
+
def health(settings: Settings = Depends(get_settings)):
|
|
14
|
+
taxonomy = load_taxonomy(settings.taxonomy_config_path)
|
|
15
|
+
return {
|
|
16
|
+
"status": "ok",
|
|
17
|
+
"app": settings.app_name,
|
|
18
|
+
"version": settings.app_version,
|
|
19
|
+
"taxonomy_version": taxonomy.version,
|
|
20
|
+
"taxonomy_baseline": taxonomy.baseline_tag,
|
|
21
|
+
"priority_category": taxonomy.priority_category,
|
|
22
|
+
"azure_configured": bool(settings.azure_text_analytics_endpoint),
|
|
23
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
api/routes/tracking.py — Escalation-chain tracking endpoints.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
6
|
+
|
|
7
|
+
from narrative_harm_classifier.core.models import ClassifyRequest
|
|
8
|
+
from narrative_harm_classifier.core.config import get_settings, Settings
|
|
9
|
+
from narrative_harm_classifier.classifier.factory import build_tracker
|
|
10
|
+
from narrative_harm_classifier.classifier.tracking.models import SourceProfile, Observation, ChainVerification
|
|
11
|
+
from narrative_harm_classifier.classifier.tracking.tracker import EscalationTracker
|
|
12
|
+
|
|
13
|
+
router = APIRouter()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_tracker(settings: Settings = Depends(get_settings)) -> EscalationTracker:
|
|
17
|
+
return build_tracker(settings)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.post(
|
|
21
|
+
"/{source_id}/observe",
|
|
22
|
+
response_model=Observation,
|
|
23
|
+
summary="Classify a text and record it against a tracked source",
|
|
24
|
+
description=(
|
|
25
|
+
"Classifies the text and appends it to the source's observation history, "
|
|
26
|
+
"used to compute escalation trend and risk level over time."
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
def observe(
|
|
30
|
+
source_id: str,
|
|
31
|
+
request: ClassifyRequest,
|
|
32
|
+
tracker: EscalationTracker = Depends(get_tracker),
|
|
33
|
+
) -> Observation:
|
|
34
|
+
return tracker.observe(source_id, request)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.get(
|
|
38
|
+
"/{source_id}",
|
|
39
|
+
response_model=SourceProfile,
|
|
40
|
+
summary="Get a tracked source's escalation profile",
|
|
41
|
+
)
|
|
42
|
+
def get_profile(
|
|
43
|
+
source_id: str,
|
|
44
|
+
window: int = 20,
|
|
45
|
+
tracker: EscalationTracker = Depends(get_tracker),
|
|
46
|
+
) -> SourceProfile:
|
|
47
|
+
profile = tracker.profile(source_id, window=window)
|
|
48
|
+
if profile.observation_count == 0:
|
|
49
|
+
raise HTTPException(status_code=404, detail=f"No observations recorded for source '{source_id}'")
|
|
50
|
+
return profile
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@router.get(
|
|
54
|
+
"",
|
|
55
|
+
response_model=list[SourceProfile],
|
|
56
|
+
summary="List all tracked sources, sorted by risk (highest first)",
|
|
57
|
+
)
|
|
58
|
+
def list_profiles(
|
|
59
|
+
window: int = 20,
|
|
60
|
+
tracker: EscalationTracker = Depends(get_tracker),
|
|
61
|
+
) -> list[SourceProfile]:
|
|
62
|
+
return tracker.list_profiles(window=window)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@router.get(
|
|
66
|
+
"/{source_id}/verify",
|
|
67
|
+
response_model=ChainVerification,
|
|
68
|
+
summary="Verify the tamper-evident hash chain for a source's observation history",
|
|
69
|
+
description=(
|
|
70
|
+
"Recomputes the hash chain over the full stored history and confirms it's intact. "
|
|
71
|
+
"Detects tampering with any historical record; does not prevent it."
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
def verify_chain(
|
|
75
|
+
source_id: str,
|
|
76
|
+
tracker: EscalationTracker = Depends(get_tracker),
|
|
77
|
+
) -> ChainVerification:
|
|
78
|
+
result = tracker.verify_chain(source_id)
|
|
79
|
+
if result.observation_count == 0:
|
|
80
|
+
raise HTTPException(status_code=404, detail=f"No observations recorded for source '{source_id}'")
|
|
81
|
+
return result
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
api/routes/validate.py — Validation endpoints.
|
|
3
|
+
Runs held-out sample validation and returns performance reports.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
7
|
+
from narrative_harm_classifier.core.models import ValidationSample, ValidationReport
|
|
8
|
+
from narrative_harm_classifier.core.config import get_settings, Settings
|
|
9
|
+
from narrative_harm_classifier.classifier.factory import build_validator
|
|
10
|
+
from narrative_harm_classifier.classifier.validators.performance import (
|
|
11
|
+
PerformanceValidator,
|
|
12
|
+
DEHUMANIZATION_VALIDATION_SAMPLES,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
router = APIRouter()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_validator(settings: Settings = Depends(get_settings)) -> PerformanceValidator:
|
|
19
|
+
return build_validator(settings)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.post(
|
|
23
|
+
"/dehumanization",
|
|
24
|
+
response_model=ValidationReport,
|
|
25
|
+
summary="Run Phase 1 end-to-end validation for priority category: dehumanization",
|
|
26
|
+
description=(
|
|
27
|
+
"Validates the dehumanization category against the built-in held-out sample set. "
|
|
28
|
+
"Checks Precision ≥ 0.70, Recall ≥ 0.65, FPR ≤ 0.20. "
|
|
29
|
+
"This is the Phase 1 milestone validation gate."
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
def validate_dehumanization(
|
|
33
|
+
validator: PerformanceValidator = Depends(get_validator),
|
|
34
|
+
) -> ValidationReport:
|
|
35
|
+
try:
|
|
36
|
+
return validator.validate_category(
|
|
37
|
+
category_name="dehumanization",
|
|
38
|
+
samples=DEHUMANIZATION_VALIDATION_SAMPLES,
|
|
39
|
+
)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.post(
|
|
45
|
+
"/custom",
|
|
46
|
+
response_model=ValidationReport,
|
|
47
|
+
summary="Run validation against a custom sample set",
|
|
48
|
+
)
|
|
49
|
+
def validate_custom(
|
|
50
|
+
category: str,
|
|
51
|
+
samples: list[ValidationSample],
|
|
52
|
+
validator: PerformanceValidator = Depends(get_validator),
|
|
53
|
+
) -> ValidationReport:
|
|
54
|
+
if not samples:
|
|
55
|
+
raise HTTPException(status_code=400, detail="At least one sample required")
|
|
56
|
+
try:
|
|
57
|
+
return validator.validate_category(category_name=category, samples=samples)
|
|
58
|
+
except ValueError as e:
|
|
59
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
60
|
+
except Exception as e:
|
|
61
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
classifier/counter_narrative.py — General counter-narrative guidance per harm mechanism.
|
|
3
|
+
|
|
4
|
+
Deliberately general, templated guidance grounded in the public
|
|
5
|
+
"acknowledge -> redirect -> inform" counter-messaging framework (the
|
|
6
|
+
methodology behind projects like Moonshot CVE and the Redirect Method), not
|
|
7
|
+
auto-generated bespoke rebuttal text for the specific input. Generating
|
|
8
|
+
custom rebuttal text is a materially harder, riskier text-generation problem
|
|
9
|
+
(prone to tone-deaf or factually wrong output) and is out of scope here —
|
|
10
|
+
this gives a human moderator/responder a starting frame, not a script to
|
|
11
|
+
paste verbatim.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
_GUIDANCE: dict[str, str] = {
|
|
17
|
+
"animalization": (
|
|
18
|
+
"Dehumanizing comparisons (to animals, vermin, disease) are a documented precursor to "
|
|
19
|
+
"real-world violence against targeted groups. Effective counter-messaging typically: "
|
|
20
|
+
"(1) avoids repeating or amplifying the dehumanizing frame itself, (2) affirms the "
|
|
21
|
+
"target group's humanity with specific, concrete facts rather than abstract appeals, "
|
|
22
|
+
"(3) redirects toward the underlying grievance being exploited, if any."
|
|
23
|
+
),
|
|
24
|
+
"demonization": (
|
|
25
|
+
"Framing a group as evil or supernaturally malevolent shuts down empathy and factual "
|
|
26
|
+
"engagement. Counter-messaging typically works better by naming the rhetorical move "
|
|
27
|
+
"explicitly (\"this frames an entire group as inherently evil\") and redirecting to "
|
|
28
|
+
"specific, falsifiable claims rather than arguing the moral framing directly."
|
|
29
|
+
),
|
|
30
|
+
"objectification": (
|
|
31
|
+
"Reducing a group to objects or property strips away agency and interiority. "
|
|
32
|
+
"Counter-messaging typically centers first-person voices from the affected group and "
|
|
33
|
+
"concrete examples of agency, rather than abstract arguments against the framing."
|
|
34
|
+
),
|
|
35
|
+
"criminalization": (
|
|
36
|
+
"Blanket criminal framing of a group is usually contradicted by the actual data. "
|
|
37
|
+
"Effective counter-messaging leads with accurate, sourced statistics and specific "
|
|
38
|
+
"counterexamples rather than general appeals not to stereotype."
|
|
39
|
+
),
|
|
40
|
+
"direct_call_to_violence": (
|
|
41
|
+
"Explicit calls to violence should typically be escalated for human review and, where "
|
|
42
|
+
"applicable, reported to platform trust & safety or law enforcement channels rather than "
|
|
43
|
+
"countered by automated messaging alone. If counter-messaging is used at all, prioritize "
|
|
44
|
+
"de-escalation and directing at-risk viewers toward support resources over debating the "
|
|
45
|
+
"claim itself."
|
|
46
|
+
),
|
|
47
|
+
"false_attribution": (
|
|
48
|
+
"Claims about a group's supposed hidden agenda are typically unfalsifiable by design. "
|
|
49
|
+
"Counter-messaging tends to work better by asking what specific, checkable evidence is "
|
|
50
|
+
"being offered (usually none) rather than trying to disprove an unfalsifiable claim "
|
|
51
|
+
"directly."
|
|
52
|
+
),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_DEFAULT_GUIDANCE = (
|
|
56
|
+
"Consider whether a direct rebuttal, a redirect to factual information, or escalation to "
|
|
57
|
+
"human review is the most appropriate response before responding automatically."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def guidance_for(harm_mechanism: Optional[str]) -> Optional[str]:
|
|
62
|
+
"""Return general counter-narrative guidance for a harm mechanism, or None if harmless."""
|
|
63
|
+
if not harm_mechanism:
|
|
64
|
+
return None
|
|
65
|
+
return _GUIDANCE.get(harm_mechanism, _DEFAULT_GUIDANCE)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
classifier/factory.py — Shared construction of engine/tracker/runner/validator.
|
|
3
|
+
|
|
4
|
+
Every API route and every CLI command previously rebuilt "load taxonomy +
|
|
5
|
+
build Azure client + build ClassificationEngine" independently (six
|
|
6
|
+
near-identical copies across api/routes/*.py and cli.py). Centralizing it
|
|
7
|
+
here means a change to how the engine is constructed (e.g. adding
|
|
8
|
+
dog-whistle lexicon loading) happens once.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from narrative_harm_classifier.core.config import Settings
|
|
12
|
+
from narrative_harm_classifier.classifier.taxonomy.loader import load_taxonomy, TaxonomyConfig
|
|
13
|
+
from narrative_harm_classifier.classifier.rules.engine import ClassificationEngine
|
|
14
|
+
from narrative_harm_classifier.classifier.rules.azure_nlp import AzureNLPClient
|
|
15
|
+
from narrative_harm_classifier.classifier.rules.dogwhistles import load_dogwhistles, DogwhistleLexicon
|
|
16
|
+
from narrative_harm_classifier.classifier.tracking.store import get_store, TrackingStore
|
|
17
|
+
from narrative_harm_classifier.classifier.tracking.tracker import EscalationTracker
|
|
18
|
+
from narrative_harm_classifier.classifier.validators.benchmark import BenchmarkRunner
|
|
19
|
+
from narrative_harm_classifier.classifier.validators.performance import PerformanceValidator
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_taxonomy(settings: Settings) -> TaxonomyConfig:
|
|
23
|
+
return load_taxonomy(settings.taxonomy_config_path)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_azure_client(settings: Settings) -> AzureNLPClient:
|
|
27
|
+
return AzureNLPClient(
|
|
28
|
+
endpoint=settings.azure_text_analytics_endpoint,
|
|
29
|
+
key=settings.azure_text_analytics_key,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_dogwhistle_lexicon(settings: Settings) -> DogwhistleLexicon:
|
|
34
|
+
return load_dogwhistles(settings.dogwhistles_path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_engine(settings: Settings) -> ClassificationEngine:
|
|
38
|
+
return ClassificationEngine(
|
|
39
|
+
taxonomy=build_taxonomy(settings),
|
|
40
|
+
azure_client=build_azure_client(settings),
|
|
41
|
+
patterns_dir=settings.patterns_dir,
|
|
42
|
+
dogwhistles=build_dogwhistle_lexicon(settings),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_tracker(settings: Settings) -> EscalationTracker:
|
|
47
|
+
store: TrackingStore = get_store(settings.effective_tracking_db_url)
|
|
48
|
+
return EscalationTracker(engine=build_engine(settings), store=store)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_benchmark_runner(settings: Settings) -> BenchmarkRunner:
|
|
52
|
+
taxonomy = build_taxonomy(settings)
|
|
53
|
+
return BenchmarkRunner(
|
|
54
|
+
engine=build_engine(settings),
|
|
55
|
+
taxonomy_version=taxonomy.version,
|
|
56
|
+
templates_path=settings.benchmark_templates_path,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_validator(settings: Settings) -> PerformanceValidator:
|
|
61
|
+
return PerformanceValidator(engine=build_engine(settings), taxonomy=build_taxonomy(settings))
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
classifier/provenance.py — Deterministic content hashing and a tamper-evident
|
|
3
|
+
hash chain for escalation-tracking observations.
|
|
4
|
+
|
|
5
|
+
content_hash() is a pure function of (text, context, taxonomy_version): the
|
|
6
|
+
same input always produces the same hash regardless of when it's computed,
|
|
7
|
+
so it can be used to verify "this exact text was classified under this
|
|
8
|
+
exact taxonomy version" independent of any particular run.
|
|
9
|
+
|
|
10
|
+
record_hash() chains each Observation to the one before it (genesis
|
|
11
|
+
GENESIS_HASH for the first record in a source's history), the same idea as
|
|
12
|
+
a git commit chain or a minimal Merkle-style ledger: altering any historical
|
|
13
|
+
record changes its hash, which no longer matches what every later record's
|
|
14
|
+
hash was computed from — making tampering detectable, not prevented.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
GENESIS_HASH = "0" * 64
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def content_hash(text: str, context: Optional[str], taxonomy_version: str) -> str:
|
|
25
|
+
payload = f"{text}\x1f{context or ''}\x1f{taxonomy_version}"
|
|
26
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def record_hash(
|
|
30
|
+
prev_hash: str,
|
|
31
|
+
source_id: str,
|
|
32
|
+
text_excerpt: str,
|
|
33
|
+
is_harmful: bool,
|
|
34
|
+
harm_mechanism: Optional[str],
|
|
35
|
+
confidence: float,
|
|
36
|
+
observed_at: datetime,
|
|
37
|
+
content_hash_value: str,
|
|
38
|
+
) -> str:
|
|
39
|
+
payload = "\x1f".join(
|
|
40
|
+
[
|
|
41
|
+
prev_hash,
|
|
42
|
+
source_id,
|
|
43
|
+
text_excerpt,
|
|
44
|
+
str(is_harmful),
|
|
45
|
+
harm_mechanism or "",
|
|
46
|
+
f"{confidence:.6f}",
|
|
47
|
+
observed_at.isoformat(),
|
|
48
|
+
content_hash_value,
|
|
49
|
+
]
|
|
50
|
+
)
|
|
51
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
File without changes
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
classifier/rules/azure_nlp.py — Azure Text Analytics connector.
|
|
3
|
+
|
|
4
|
+
Wraps Azure Cognitive Services Text Analytics for:
|
|
5
|
+
- Sentiment analysis (used as negative signal amplifier)
|
|
6
|
+
- Named Entity Recognition (detects group/identity mentions)
|
|
7
|
+
- Key phrase extraction (surface-level harm signal detection)
|
|
8
|
+
|
|
9
|
+
Falls back gracefully when Azure credentials are not configured (dev mode).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class AzureNLPResult:
|
|
21
|
+
sentiment: str # positive | negative | neutral | mixed
|
|
22
|
+
sentiment_score_negative: float # 0.0–1.0
|
|
23
|
+
entities: list[dict] # [{text, category, confidence}]
|
|
24
|
+
key_phrases: list[str]
|
|
25
|
+
language: str
|
|
26
|
+
is_fallback: bool = False # True when Azure not configured
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AzureNLPClient:
|
|
30
|
+
"""
|
|
31
|
+
Azure Text Analytics client with graceful dev-mode fallback.
|
|
32
|
+
|
|
33
|
+
In production: set AZURE_TEXT_ANALYTICS_ENDPOINT and AZURE_TEXT_ANALYTICS_KEY.
|
|
34
|
+
In dev/test: client operates in fallback mode with neutral scores.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, endpoint: str = "", key: str = ""):
|
|
38
|
+
self.endpoint = endpoint
|
|
39
|
+
self.key = key
|
|
40
|
+
self._client = None
|
|
41
|
+
|
|
42
|
+
if endpoint and key:
|
|
43
|
+
try:
|
|
44
|
+
from azure.ai.textanalytics import TextAnalyticsClient
|
|
45
|
+
from azure.core.credentials import AzureKeyCredential
|
|
46
|
+
self._client = TextAnalyticsClient(
|
|
47
|
+
endpoint=endpoint,
|
|
48
|
+
credential=AzureKeyCredential(key)
|
|
49
|
+
)
|
|
50
|
+
logger.info("Azure Text Analytics client initialized")
|
|
51
|
+
except ImportError:
|
|
52
|
+
logger.warning("azure-ai-textanalytics not installed — running in fallback mode")
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.warning(f"Azure Text Analytics init failed: {e} — running in fallback mode")
|
|
55
|
+
else:
|
|
56
|
+
logger.info("Azure credentials not configured — running in fallback/dev mode")
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def is_configured(self) -> bool:
|
|
60
|
+
return self._client is not None
|
|
61
|
+
|
|
62
|
+
def analyze(self, text: str, language: str = "en") -> AzureNLPResult:
|
|
63
|
+
"""Run sentiment + NER + key phrases in a single batched call."""
|
|
64
|
+
if not self.is_configured:
|
|
65
|
+
return self._fallback_result(text)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
from azure.ai.textanalytics import (
|
|
69
|
+
RecognizeEntitiesAction,
|
|
70
|
+
AnalyzeSentimentAction,
|
|
71
|
+
ExtractKeyPhrasesAction,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
poller = self._client.begin_analyze_actions(
|
|
75
|
+
documents=[{"id": "1", "text": text, "language": language}],
|
|
76
|
+
actions=[
|
|
77
|
+
AnalyzeSentimentAction(),
|
|
78
|
+
RecognizeEntitiesAction(),
|
|
79
|
+
ExtractKeyPhrasesAction(),
|
|
80
|
+
],
|
|
81
|
+
)
|
|
82
|
+
results = list(poller.result())
|
|
83
|
+
|
|
84
|
+
sentiment_result = None
|
|
85
|
+
entity_result = None
|
|
86
|
+
keyphrase_result = None
|
|
87
|
+
|
|
88
|
+
for action_result in results[0]:
|
|
89
|
+
if action_result.kind == "SentimentAnalysis" and not action_result.is_error:
|
|
90
|
+
sentiment_result = action_result
|
|
91
|
+
elif action_result.kind == "EntityRecognition" and not action_result.is_error:
|
|
92
|
+
entity_result = action_result
|
|
93
|
+
elif action_result.kind == "KeyPhraseExtraction" and not action_result.is_error:
|
|
94
|
+
keyphrase_result = action_result
|
|
95
|
+
|
|
96
|
+
sentiment = "neutral"
|
|
97
|
+
neg_score = 0.0
|
|
98
|
+
if sentiment_result:
|
|
99
|
+
sentiment = sentiment_result.sentiment
|
|
100
|
+
neg_score = sentiment_result.confidence_scores.negative
|
|
101
|
+
|
|
102
|
+
entities = []
|
|
103
|
+
if entity_result:
|
|
104
|
+
for ent in entity_result.entities:
|
|
105
|
+
entities.append({
|
|
106
|
+
"text": ent.text,
|
|
107
|
+
"category": ent.category,
|
|
108
|
+
"confidence": ent.confidence_score,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
key_phrases = []
|
|
112
|
+
if keyphrase_result:
|
|
113
|
+
key_phrases = list(keyphrase_result.key_phrases)
|
|
114
|
+
|
|
115
|
+
return AzureNLPResult(
|
|
116
|
+
sentiment=sentiment,
|
|
117
|
+
sentiment_score_negative=neg_score,
|
|
118
|
+
entities=entities,
|
|
119
|
+
key_phrases=key_phrases,
|
|
120
|
+
language=language,
|
|
121
|
+
is_fallback=False,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.error(f"Azure NLP analysis failed: {e}")
|
|
126
|
+
return self._fallback_result(text)
|
|
127
|
+
|
|
128
|
+
def _fallback_result(self, text: str) -> AzureNLPResult:
|
|
129
|
+
"""Dev-mode fallback: neutral scores, no entities."""
|
|
130
|
+
return AzureNLPResult(
|
|
131
|
+
sentiment="neutral",
|
|
132
|
+
sentiment_score_negative=0.0,
|
|
133
|
+
entities=[],
|
|
134
|
+
key_phrases=[],
|
|
135
|
+
language="en",
|
|
136
|
+
is_fallback=True,
|
|
137
|
+
)
|