diff --git a/nadirclaw/server.py b/nadirclaw/server.py index 3373f86..f8d447d 100644 --- a/nadirclaw/server.py +++ b/nadirclaw/server.py @@ -24,6 +24,48 @@ from pydantic import BaseModel, model_validator from sse_starlette.sse import EventSourceResponse + +# --------------------------------------------------------------------------- +# Classifier input cleaner +# --------------------------------------------------------------------------- +# Strips configured regex patterns from user prompts *before* classification +# so agent metadata envelopes (memory context, system notes, etc.) do not +# inflate the complexity score. The LLM still sees the full text --- this +# only affects the classifier. +# Set NADIRCLAW_CLASSIFIER_STRIP_PATTERNS in the environment. +# --------------------------------------------------------------------------- +_strip_regex: Optional[re.Pattern] = None + +def _compile_strip_regex() -> Optional[re.Pattern]: + """Compile the classifier strip pattern from settings, or None if empty.""" + from nadirclaw.settings import settings as _s + raw = _s.CLASSIFIER_STRIP_PATTERNS + if not raw: + return None + try: + return re.compile(raw, re.DOTALL) + except re.error: + logger = logging.getLogger(__name__) + logger.warning( + "Invalid NADIRCLAW_CLASSIFIER_STRIP_PATTERNS=%r - ignoring. " + "Check your regex syntax.", + raw, + ) + return None + +def _strip_classifier_input(text: str) -> str: + """Strip configured patterns from classifier input text.""" + global _strip_regex + if _strip_regex is None: + _strip_regex = _compile_strip_regex() + if not _strip_regex or not text: + return text + stripped = _strip_regex.sub('', text).strip() + # Guard against an over-broad pattern consuming the whole prompt: an empty + # classifier input would silently route everything to the cheapest tier. + return stripped or text +# --------------------------------------------------------------------------- + import os from nadirclaw import __version__ @@ -533,6 +575,8 @@ async def _smart_route_full( """Smart route for full completions.""" user_msgs = [m.text_content() for m in messages if m.role == "user"] prompt = user_msgs[-1] if user_msgs else "" + # Strip agent metadata so they do not inflate complexity score. + prompt = _strip_classifier_input(prompt) system_msg = next((m.text_content() for m in messages if m.role in ("system", "developer")), "") return await _smart_route_analysis(prompt, system_msg, user) @@ -547,8 +591,9 @@ async def classify_prompt( current_user: UserSession = Depends(validate_local_auth), ) -> Dict[str, Any]: """Classify a prompt without calling any LLM.""" + clean_prompt = _strip_classifier_input(request.prompt) _, analysis = await _smart_route_analysis( - request.prompt, request.system_message or "", current_user + clean_prompt, request.system_message or "", current_user ) _log_request({ @@ -571,7 +616,8 @@ async def classify_batch( """Classify multiple prompts at once.""" results = [] for prompt in request.prompts: - _, analysis = await _smart_route_analysis(prompt, "", current_user) + clean_prompt = _strip_classifier_input(prompt) + _, analysis = await _smart_route_analysis(clean_prompt, "", current_user) results.append({ "prompt": prompt, "selected_model": analysis.get("selected_model"), diff --git a/nadirclaw/settings.py b/nadirclaw/settings.py index 604b608..3c7b092 100644 --- a/nadirclaw/settings.py +++ b/nadirclaw/settings.py @@ -523,5 +523,25 @@ def CENTROID_DIR(self) -> "Path | None": return Path(val).expanduser() return None + @property + def CLASSIFIER_STRIP_PATTERNS(self) -> str: + """Regex patterns to strip from user prompts *before* classification. + + Agent frameworks often wrap the human's actual prompt in a structured + envelope (metadata, memory context, system notes, etc.) that does not + reflect the complexity of the request. Stripping these blocks lets + the classifier see the user's actual intent. + + Set this to a regex using Python's ``re`` syntax; ``re.DOTALL`` is + applied internally. Example for typical envelope patterns:: + + .*?(?:|\\Z)|\\[system note:.*?\\] + + Default: empty string = no stripping. + """ + return os.getenv("NADIRCLAW_CLASSIFIER_STRIP_PATTERNS", "") + + + settings = Settings() diff --git a/tests/test_classifier_strip.py b/tests/test_classifier_strip.py new file mode 100644 index 0000000..a34ced7 --- /dev/null +++ b/tests/test_classifier_strip.py @@ -0,0 +1,79 @@ +"""Tests for the classifier input cleaner (NADIRCLAW_CLASSIFIER_STRIP_PATTERNS). + +Locks in the contract for ``_strip_classifier_input`` / ``_compile_strip_regex`` +in ``nadirclaw.server``: + + (a) unset env var -> identity (no stripping) + (b) a configured pattern -> the matched envelope is removed + (c) an invalid regex -> no-op + warning, never a crash + (d) an over-broad pattern -> falls back to the original prompt rather than + emptying the classifier input + +The module-level ``_strip_regex`` cache is reset before each case so the +pattern is recompiled from the (monkeypatched) environment. +""" + +import pytest + +import nadirclaw.server as server + + +@pytest.fixture(autouse=True) +def _reset_strip_cache(monkeypatch): + """Clear the compiled-regex cache and env var before/after each test.""" + monkeypatch.delenv("NADIRCLAW_CLASSIFIER_STRIP_PATTERNS", raising=False) + server._strip_regex = None + yield + server._strip_regex = None + + +def _set_pattern(monkeypatch, pattern: str): + monkeypatch.setenv("NADIRCLAW_CLASSIFIER_STRIP_PATTERNS", pattern) + server._strip_regex = None # force recompile from the new env value + + +def test_unset_is_identity(monkeypatch): + text = "metaWhat is the capital of France?" + assert server._strip_classifier_input(text) == text + + +def test_empty_text_is_returned_unchanged(monkeypatch): + _set_pattern(monkeypatch, r".*?") + assert server._strip_classifier_input("") == "" + + +def test_pattern_strips_envelope(monkeypatch): + _set_pattern(monkeypatch, r".*?") + text = "memory: 42 factsSummarize this." + assert server._strip_classifier_input(text) == "Summarize this." + + +def test_pattern_strips_across_newlines_dotall(monkeypatch): + # re.DOTALL is applied internally, so '.' spans newlines. + _set_pattern(monkeypatch, r"\[system note:.*?\]") + text = "[system note:\nremember the user prefers\nterse answers]Hi" + assert server._strip_classifier_input(text) == "Hi" + + +def test_invalid_regex_is_noop_and_warns(monkeypatch, caplog): + _set_pattern(monkeypatch, r"(unclosed") # invalid: unbalanced ( + text = "(unclosed keep me intact" + with caplog.at_level("WARNING"): + out = server._strip_classifier_input(text) + assert out == text # never crashes, returns input untouched + assert any("NADIRCLAW_CLASSIFIER_STRIP_PATTERNS" in r.message for r in caplog.records) + + +def test_overbroad_pattern_falls_back_to_original(monkeypatch): + # A greedy pattern that consumes the whole prompt must not empty the + # classifier input (which would route everything to the cheapest tier). + _set_pattern(monkeypatch, r".*") + text = "Implement a distributed consensus algorithm." + assert server._strip_classifier_input(text) == text + + +def test_partial_strip_leaving_content_is_kept(monkeypatch): + # When stripping still leaves real content, that content is returned. + _set_pattern(monkeypatch, r".*?") + text = "tooling real question more" + assert server._strip_classifier_input(text) == "real question"