atman-linguistic-sensor / lib /affect /refusal_detector.py
hleserg's picture
feat: initial deploy β€” Atman Linguistic Sensor v1
fe62539
Raw
History Blame Contribute Delete
18.9 kB
"""
Value-based refusal detector for agent text.
Three layers, text-only β€” no LLM required:
1. Morphology (pymorphy3): normalized forms of refusal verbs
and constructions like "negation + modal verb".
2. Semantic value context (NRC EmoLex): high density of disgust or anger
signals moral/ethical context, distinguishing "I won't participate in
deception" from "no, that's incorrect".
3. Capability exclusion: if a refusal verb is near a technical action verb,
it's "I can't generate" rather than "I won't cause harm".
Optional LLM-fallback connects via `RefusalDetectorConfig` and is used only
when both layers don't give a confident answer. If LLM is not configured,
the system stays silent.
"""
from __future__ import annotations
import math
import re
from collections.abc import Callable
from dataclasses import dataclass
from lib.affect.emolex.emolex import _lemma_ru_cached as lemmatize
from lib.affect.emolex.emolex import emotion_score, tokenize
# ---------------------------------------------------------------------------
# Normalized forms β€” value refusal vocabulary
# ---------------------------------------------------------------------------
# Verbs that are inherently refusals
_REFUSAL_VERB_NORMALS = frozenset(
[
# Russian
"ΠΎΡ‚ΠΊΠ°Π·Ρ‹Π²Π°Ρ‚ΡŒΡΡ",
"ΠΎΡ‚ΠΊΠ°Π·Π°Ρ‚ΡŒΡΡ",
"ΠΎΡ‚ΠΊΠ»ΠΎΠ½ΠΈΡ‚ΡŒ",
"ΠΎΡ‚ΠΊΠ»ΠΎΠ½ΡΡ‚ΡŒ",
"Π²ΠΎΠ·Π΄Π΅Ρ€ΠΆΠ°Ρ‚ΡŒΡΡ",
"Π²ΠΎΠ·Π΄Π΅Ρ€ΠΆΠΈΠ²Π°Ρ‚ΡŒΡΡ",
"ΠΎΡ‚Π²Π΅Ρ€Π³Π½ΡƒΡ‚ΡŒ",
"ΠΎΡ‚Π²Π΅Ρ€Π³Π°Ρ‚ΡŒ",
# English (pymorphy3 passes latin as-is, lowercase)
"refuse",
"decline",
"reject",
"abstain",
]
)
# Verbs that become refusal through negation: "won't", "will not", "don't want"
_MODAL_NEGATABLE = frozenset(
[
# Russian
"ΠΌΠΎΡ‡ΡŒ", # Π½Π΅ ΠΌΠΎΠ³Ρƒ
"ΡΡ‚Π°Ρ‚ΡŒ", # Π½Π΅ стану
"Ρ…ΠΎΡ‚Π΅Ρ‚ΡŒ", # Π½Π΅ Ρ…ΠΎΡ‡Ρƒ
"ΡΠΎΠ±ΠΈΡ€Π°Ρ‚ΡŒΡΡ", # Π½Π΅ ΡΠΎΠ±ΠΈΡ€Π°ΡŽΡΡŒ
"ΠΏΠΎΠΌΠΎΠ³Π°Ρ‚ΡŒ", # Π½Π΅ Π±ΡƒΠ΄Ρƒ ΠΏΠΎΠΌΠΎΠ³Π°Ρ‚ΡŒ
"ΠΏΠΎΠΌΠΎΡ‡ΡŒ",
"Π΄Π΅Π»Π°Ρ‚ΡŒ",
"ΡƒΡ‡Π°ΡΡ‚Π²ΠΎΠ²Π°Ρ‚ΡŒ",
# English
"will", # will not / won't
"can", # cannot / can't
"going", # not going to
"participate",
"assist",
"help",
]
)
# Technical inability context β†’ NOT a value refusal
_CAPABILITY_NORMALS = frozenset(
[
"Π³Π΅Π½Π΅Ρ€ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ",
"Ρ€ΠΈΡΠΎΠ²Π°Ρ‚ΡŒ",
# create/build β€” capability, but only if no moral context
"ΡΠΎΠ·Π΄Π°Π²Π°Ρ‚ΡŒ",
"ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ",
# run/launch β€” both aspects
"Π·Π°ΠΏΡƒΡΠΊΠ°Ρ‚ΡŒ",
"Π·Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ",
# install/download
"ΡƒΡΡ‚Π°Π½ΠΎΠ²ΠΈΡ‚ΡŒ",
"ΡƒΡΡ‚Π°Π½Π°Π²Π»ΠΈΠ²Π°Ρ‚ΡŒ",
"Π·Π°Π³Ρ€ΡƒΠΆΠ°Ρ‚ΡŒ",
"Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ",
# open/connect/get
"ΠΎΡ‚ΠΊΡ€Ρ‹Π²Π°Ρ‚ΡŒ",
"ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ",
"ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π°Ρ‚ΡŒΡΡ",
"ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒΡΡ",
"ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ",
"ΠΏΠΎΠ»ΡƒΡ‡Π°Ρ‚ΡŒ",
# perceive
"ΡΠ»Ρ‹ΡˆΠ°Ρ‚ΡŒ",
"Π²ΠΈΠ΄Π΅Ρ‚ΡŒ",
"Ρ‡ΠΈΡ‚Π°Ρ‚ΡŒ",
"ΡƒΠΌΠ΅Ρ‚ΡŒ", # Β«Π½Π΅ ΡƒΠΌΠ΅ΡŽΒ» β€” capability
"Π·Π½Π°Ρ‚ΡŒ", # Β«Π½Π΅ знаю» β€” knowledge gap
"ΠΈΠΌΠ΅Ρ‚ΡŒ", # Β«Π½Π΅ имСю возмоТности» β€” capability
"Π²Ρ‹ΠΏΠΎΠ»Π½ΠΈΡ‚ΡŒ",
"Π²Ρ‹ΠΏΠΎΠ»Π½ΡΡ‚ΡŒ", # Β«Π½Π΅ ΠΌΠΎΠ³Ρƒ Π²Ρ‹ΠΏΠΎΠ»Π½ΠΈΡ‚ΡŒΒ» β€” technical
"ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ",
"ΠΎΠ±Ρ€Π°Π±Π°Ρ‚Ρ‹Π²Π°Ρ‚ΡŒ",
# English capability verbs
"generate",
"draw",
"create",
"run",
"execute",
"install",
"download",
"upload",
"open",
"connect",
"read",
"write",
"hear",
"see",
"know", # "I don't know" β€” knowledge gap
"access",
"process",
]
)
# Explicit refusal phrases β€” when present, count as a refusal signal even
# without a canonical refusal verb or negated modal. Used to catch concise
# value-driven refusals ("ΠŸΠΎΡΡ‚ΠΎΠΌΡƒ β€” Π½Π΅Ρ‚", "So β€” no") that rephrase the
# refusal but don't use the standard morphological forms.
_EXPLICIT_REFUSAL_PHRASES = (
# Russian
"поэтому β€” Π½Π΅Ρ‚",
"поэтому Π½Π΅Ρ‚",
"ΠΌΠΎΠΉ ΠΎΡ‚Π²Π΅Ρ‚ β€” Π½Π΅Ρ‚",
"ΠΌΠΎΠΉ ΠΎΡ‚Π²Π΅Ρ‚ Π½Π΅Ρ‚",
"ΠΎΡ‚Π²Π΅Ρ‚ β€” Π½Π΅Ρ‚",
"ΠΎΡ‚Π²Π΅Ρ‚ Π½Π΅Ρ‚",
"ΠΈ Π½Π΅Ρ‚", # "...ΠΈ Π½Π΅Ρ‚." as terse refusal closer
# English
"so β€” no",
"so, no",
"so no.",
"the answer is no",
"my answer is no",
)
# Negation markers that are valid as scope indicators
_NEGATORS = frozenset(
[
# Russian
"Π½Π΅",
"Π½ΠΈ",
"нСльзя",
"Π½Π΅Π²ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎ",
"Π½Π΅Ρ‚",
# English
"not",
"never",
"cannot",
]
)
# Stems of English contractions whose negation is split by the apostrophe-stripping
# tokenizer. "won't" β†’ ["won", "t"], "don't" β†’ ["don", "t"], etc. The detector
# treats the pair (stem, "t") as a single negator.
_CONTRACTION_NEG_STEMS = frozenset(
[
"won",
"don",
"doesn",
"didn",
"isn",
"aren",
"wasn",
"weren",
"haven",
"hasn",
"hadn",
"shouldn",
"couldn",
"wouldn",
"ain",
"mustn",
"needn",
"can", # "can't" β†’ ["can", "t"]; bare "can" never reaches this list because
# it never appears together with a trailing "t" outside contractions.
]
)
# Narrower subset: contractions where the modal expresses refusal of intent,
# inability, or moral disagreement. Excludes copula ("isn't", "aren't") and
# aspect ("haven't") contractions β€” those negate state/perfect, not action.
# Used by score_refusal Pattern B to avoid firing on neutral "isn't about X".
_MODAL_REFUSAL_CONTRACTION_STEMS = frozenset(
[
"won", # won't = will not β€” intent refusal
"can", # can't = cannot β€” inability/refusal
"shouldn", # shouldn't β€” moral refusal
"wouldn", # wouldn't β€” preference refusal
"couldn", # couldn't β€” inability
"don", # don't = do not β€” action refusal
]
)
# Value-context phrases β€” when present in the same text as a refusal marker,
# they signal ethical/moral framing even when NRC density alone is too low
# (common for short or hedged English refusals). Used as a fallback to
# unblock `no_moral_context` cases that are genuinely value-driven.
#
# Only multi-word phrases tied to ethical/refusal context are included.
# Bare "against my" / "violates my" / "compromise my" / "not willing to"
# were considered but dropped (false positives in technical text:
# "works against my expectations", "violates my linter rules", etc).
_VALUE_CONTEXT_PHRASES = (
# ── Russian ──────────────────────────────────────────────────────────
"ΠΏΡ€ΠΎΡ‚ΠΈΠ² ΠΌΠΎΠΈΡ… ΠΏΡ€ΠΈΠ½Ρ†ΠΈΠΏΠΎΠ²",
"ΠΏΡ€ΠΎΡ‚ΠΈΠ² ΠΌΠΎΠΈΡ… цСнностСй",
"ΠΌΠΎΠΈΡ… ΠΏΡ€ΠΈΠ½Ρ†ΠΈΠΏΠΎΠ²",
"ΠΌΠΎΠΈΡ… цСнностСй",
"это Π½Π΅ΠΏΡ€Π°Π²ΠΈΠ»ΡŒΠ½ΠΎ",
"нСдопустимо",
"ΠΏΡ€ΠΈΡ‡ΠΈΠ½ΠΈΡ‚ Π²Ρ€Π΅Π΄",
"Π½Π°Π²Ρ€Π΅Π΄ΠΈΡ‚ Ρ‡Π΅Π»ΠΎΠ²Π΅ΠΊΡƒ",
"Π½Π°Π²Ρ€Π΅Π΄ΠΈΡ‚ людям",
"ΠΎΠ±ΠΌΠ°Π½Ρ‹Π²Π°Ρ‚ΡŒ",
"ΠΎΠ±ΠΌΠ°Π½ΡƒΡ‚ΡŒ Ρ‡Π΅Π»ΠΎΠ²Π΅ΠΊΠ°",
"ΠΎΠ±ΠΌΠ°Π½ΡƒΡ‚ΡŒ людСй",
"Π²Ρ€Π°Ρ‚ΡŒ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŽ",
"Π»Π³Π°Ρ‚ΡŒ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŽ",
"ΠΏΡ€Π΅Π΄Π°Ρ‚ΡŒ Π΄ΠΎΠ²Π΅Ρ€ΠΈΠ΅",
"ΠΏΡ€Π΅Π΄Π°ΠΌ Π΄ΠΎΠ²Π΅Ρ€ΠΈΠ΅",
"пСрСсСкаСт Ρ‡Π΅Ρ€Ρ‚Ρƒ",
"ΠΏΠ΅Ρ€Π΅ΡΠ΅Ρ‡ΡŒ Ρ‡Π΅Ρ€Ρ‚Ρƒ",
"ΠΏΡ€ΠΎΡ‚ΠΈΠ² совСсти",
# ── English ──────────────────────────────────────────────────────────
"against what i",
"against what i believe",
"against my principles",
"against my values",
"harm someone",
"harm them",
"would harm",
"could harm",
"betray",
"betraying",
"morally wrong",
"my principles",
"my values",
"crosses a line",
"cross a line",
"deceive",
"deceiving",
"dishonest",
"going against my",
"who i want to be",
"wrong to do",
)
def _window_has_negation(lemmas: list[str], end: int, size: int) -> bool:
"""True when the window of `size` lemmas ending at `end` contains a negator.
Recognises both bare negators ("not", "Π½Π΅") and the contraction-pair pattern
where the tokenizer split off the apostrophe (e.g. ["won", "t"]).
"""
start = max(0, end - size)
window = lemmas[start:end]
if any(w in _NEGATORS for w in window):
return True
for j in range(len(window) - 1):
if window[j + 1] == "t" and window[j] in _CONTRACTION_NEG_STEMS:
return True
return False
def _capability_context_near(lemmas: list[str], i: int, radius: int = 5) -> bool:
"""True iff a capability verb lemma is within Β±radius tokens of position i.
Used to distinguish "I can't generate images" (capability β€” discount)
from "I refuse to deceive. I can see your point" (value refusal, with
an unrelated capability verb later in the message).
"""
start = max(0, i - radius)
end = min(len(lemmas), i + radius + 1)
return any(lem in _CAPABILITY_NORMALS for lem in lemmas[start:end])
# NRC threshold (density per 100 tokens) for "moral context".
# EN was 2.0 β€” too low; almost any negatively-charged token cleared it,
# triggering false positives on neutral text discussing risk/concern.
_MORAL_THRESHOLD_RU = 8.0
_MORAL_THRESHOLD_EN = 4.0
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
@dataclass
class RefusalDetectorConfig:
"""
Value refusal detector configuration.
Without LLM (default):
Only text layers operate.
confidence < uncertain_threshold β†’ record nothing.
With LLM (optional):
If confidence is in uncertain zone AND llm_classifier is set,
the classifier is invoked. It makes the final decision.
"""
# Threshold below which we stay silent (avoid false positives)
min_confidence: float = 0.45
# Uncertain zone β€” delegate to LLM if configured
uncertain_low: float = 0.45
uncertain_high: float = 0.65
# Optional LLM classifier (sync: text -> bool)
# Signature: (text: str) -> bool
# If None, LLM is never used.
llm_classifier: Callable[[str], bool] | None = None
# ---------------------------------------------------------------------------
# Core logic
# ---------------------------------------------------------------------------
@dataclass
class RefusalScore:
confidence: float # 0.0–1.0
has_refusal_verb: bool
has_negated_modal: bool
has_capability_context: bool
disgust_density: float
anger_density: float
decided_by: str # "text", "llm", "below_threshold"
@property
def is_value_refusal(self) -> bool:
# Mirrors RefusalDetectorConfig.min_confidence default. Callers that
# need a different threshold should pass a config to is_value_refusal().
return self.confidence >= 0.45
def score_refusal(text: str) -> RefusalScore:
"""
Compute the degree of value refusal in text.
Returns RefusalScore with confidence 0.0–1.0.
Confidence interpretation:
< 0.45 β€” not a refusal (or uncertain)
0.45–0.65 β€” "gray zone" (LLM could help)
> 0.65 β€” value refusal with high confidence
Internal formula:
confidence = refusal_signal * moral_signal * (1 - capability_discount)
refusal_signal: 1.0 if refusal verb or negated_modal present
moral_signal: depends on disgust/anger density in NRC
capability_discount: 0.85 if capability verb is nearby
"""
# Strip think-blocks before analysis
clean = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
tokens = tokenize(clean)
if not tokens:
return RefusalScore(0.0, False, False, False, 0.0, 0.0, "below_threshold")
lemmas = [lemmatize(t) for t in tokens]
lemma_set = set(lemmas)
# ── Layer 1: morphology ──────────────────────────────────────────────
refusal_position: int | None = None
has_refusal_verb = False
has_negated_modal = False
# Pattern C β€” explicit terse refusal phrase anywhere in the text.
# Treated as equivalent to a refusal verb for the signal layer.
clean_lower = clean.lower()
if any(phrase in clean_lower for phrase in _EXPLICIT_REFUSAL_PHRASES):
has_refusal_verb = True
refusal_position = 0 # whole-text marker β€” position irrelevant
for i, lemma in enumerate(lemmas):
if lemma in _REFUSAL_VERB_NORMALS:
has_refusal_verb = True
refusal_position = i
break
if refusal_position is None:
for i, lemma in enumerate(lemmas):
# Pattern A β€” bare modal preceded by negation in window
if lemma in _MODAL_NEGATABLE and _window_has_negation(lemmas, i, 3):
has_negated_modal = True
refusal_position = i
break
# Pattern B β€” English modal-refusal contraction at position i + "t"
# at i+1. Only true refusal modals (won/can/shouldn/wouldn/couldn/don),
# NOT copula contractions (isn/aren/wasn) which negate state.
if (
lemma in _MODAL_REFUSAL_CONTRACTION_STEMS
and i + 1 < len(lemmas)
and lemmas[i + 1] == "t"
):
has_negated_modal = True
refusal_position = i
break
# Capability context: only counts if it's near the refusal verb (Β±5 tokens),
# not anywhere in the text. Previously a global set-intersection turned
# any mention of "see"/"read"/"know" into a global discount.
if refusal_position is not None:
has_capability_context = _capability_context_near(lemmas, refusal_position, radius=5)
else:
has_capability_context = bool(_CAPABILITY_NORMALS & lemma_set)
refusal_signal = 1.0 if (has_refusal_verb or has_negated_modal) else 0.0
if math.isclose(refusal_signal, 0.0):
return RefusalScore(
0.0, False, False, has_capability_context, 0.0, 0.0, "no_refusal"
)
# ── Layer 2: NRC moral context ───────────────────────────────────────
lang = "ru" if _is_mostly_cyrillic(clean) else "en"
try:
scores = emotion_score(clean, lang=lang)
except Exception:
scores = {}
disgust = float(scores.get("disgust", 0.0))
anger = float(scores.get("anger", 0.0))
fear = float(scores.get("fear", 0.0))
# Moral signal: disgust is specific to norm violations,
# anger appears with injustice; disgust weighs more.
# For English text, fear also carries ethical weight
# (harm, danger, deception), while capability refusals have fear=0.
is_ru = _is_mostly_cyrillic(clean)
if is_ru:
moral_density = disgust + anger * 0.5
moral_threshold = _MORAL_THRESHOLD_RU
else:
moral_density = disgust + anger * 0.5 + fear * 0.35
moral_threshold = _MORAL_THRESHOLD_EN
# Normalize to [0, 1]. Divisor differs: English NRC yields lower densities.
divisor = 15.0 if is_ru else 5.0
moral_signal = min(1.0, moral_density / divisor) if moral_density >= moral_threshold else 0.0
if math.isclose(moral_signal, 0.0):
# NRC density too low for moral context β€” but the agent may still be
# value-refusing in English where moral terms are often abstract
# ("against my values", "would harm", "betray", "crosses a line")
# and don't have direct NRC entries. Fallback: scan for explicit
# value-context phrases. Short refusals like "I won't do this. It's
# not a rule β€” it would harm someone" are caught here.
lower_text = clean.lower()
value_hits = sum(1 for p in _VALUE_CONTEXT_PHRASES if p in lower_text)
if value_hits >= 1:
moral_signal = 0.6 if value_hits == 1 else 0.85
else:
# No refusal-supporting context at all β€” gray zone for LLM fallback.
confidence = 0.30
return RefusalScore(
confidence,
has_refusal_verb,
has_negated_modal,
has_capability_context,
disgust,
anger,
"no_moral_context",
)
# ── Layer 3: technical inability discount ────────────────────────────
# Capability discount is now soft (0.5 not 0.85): if a refusal verb sits
# next to a capability verb ("I cannot generate images"), we still keep
# half the moral confidence β€” moral content + capability context isn't
# rare. Old 0.85 effectively vetoed any refusal that mentioned a
# capability verb anywhere in the message.
capability_discount = 0.5 if has_capability_context else 0.0
confidence = refusal_signal * (0.4 + 0.6 * moral_signal) * (1.0 - capability_discount)
return RefusalScore(
confidence=round(confidence, 3),
has_refusal_verb=has_refusal_verb,
has_negated_modal=has_negated_modal,
has_capability_context=has_capability_context,
disgust_density=disgust,
anger_density=anger,
decided_by="text",
)
def is_value_refusal(
text: str,
config: RefusalDetectorConfig | None = None,
) -> bool:
"""
Main entry point: True if text contains a value refusal.
Without LLM: decision by text only.
With LLM (config.llm_classifier set): invoked in uncertain zone.
"""
cfg = config or RefusalDetectorConfig()
result = score_refusal(text)
if result.confidence < cfg.min_confidence:
return False
if (
cfg.llm_classifier is not None
and cfg.uncertain_low <= result.confidence <= cfg.uncertain_high
):
try:
return cfg.llm_classifier(text)
except Exception: # nosec B110
pass # LLM unavailable β€” decide by text only
return result.confidence >= cfg.min_confidence
def _is_mostly_cyrillic(text: str) -> bool:
sample = text[:200]
cyr = sum(1 for c in sample if "Π€" <= c <= "ΣΏ")
lat = sum(1 for c in sample if "a" <= c.lower() <= "z")
return cyr >= lat