nexo-brain 7.9.0 → 7.9.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.0",
3
+ "version": "7.9.2",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.9.0` is the current packaged-runtime line. Minor release that ships the foundation of the semantic stack (router + reasoner + CLI) under the ONEPASS LLM Coverage plan, plus two product-bug fixes observed in the wild on 2026-04-23. New `src/semantic_router.py` exposes 18 named `decision_kinds` (13 textual + 5 code-aware) with a per-kind policy table and the layer chain `fast_local → semantic_reasoner → remote_fallback`. New `src/semantic_reasoner.py` adds Mode A (`multipass_local`: reuses the mDeBERTa pin with three prompt-perturbed passes + majority vote + 0.75 floor) and Mode B (`cached_llm`: wrapper over `call_model_raw` with a pid+uuid atomic-write 24h-TTL disk cache at `~/.nexo/runtime/operations/semantic-reasoner-cache.json`, SHA-256 keyed by `decision_kind` + normalized input, LRU-bounded at 2000 entries, corrupt entries dropped on read). New `scripts/semantic-classify.py` JSON-in JSON-out CLI lets external MCP clients (including the closed-source NEXO Desktop companion) query Brain as the single semantic authority. New `NEXO_SEMANTIC_REASONER` kill switch (`0`/`off`/`false`/`no`/`disable`/`disabled`) honours the plan mandate for a runtime opt-out separate from `NEXO_LOCAL_CLASSIFIER`. Bug fixes: `bin/nexo-brain.js` upgrade flow now copies `templates/` root the same way fresh install and same-version refresh already did (Maria iMac 7.1.10→7.8.1 upgrade had lost 27 core-prompts templates and broken post-update import verification); and `tool-enforcement-map.json` `nexo_startup.enforcement.inject_prompt` now instructs the model to preload the 13 `mcp__nexo__*` protocol tools via `ToolSearch` before calling `nexo_startup` when the host MCP client defers tool schemas (Claude Code with many MCPs installed). Audit-driven hardening: router/reasoner defensively use `getattr` over the `call_model_raw` module and add a trailing `except Exception` so provider errors degrade with `remote_error` instead of propagating; cache writes use pid+uuid tmp + `fsync` + `os.replace` to survive concurrent writers; `NEXO_SEMANTIC_REASONER_TTL` parse tolerates malformed values. Tests: +50 (22 router, 20 reasoner, 8 CLI). Per-site migration of existing callers (`session_end_intent`, `r14`, `r16`, `r17`, `r20`, `r34`, T4 gates, `tools_drive`, `nexo-followup-runner`) is explicitly deferred to follow-up patch releases and tracked as `NF-SEMANTIC-ROUTER-SITE-MIGRATION`; nothing in this release changes the behaviour of the existing callers. Companion coordinated release: NEXO Desktop v0.28.0.
21
+ Version `7.9.2` is the current packaged-runtime line. Patch release that completes the Brain semantic-router site migration: the remaining decision callers now route through `semantic_router.route(...)` with named `decision_kind` policies (`r20_constant_change`, `r34_identity_coherence`, `t4_r15`, `t4_r23e`, `t4_r23f`, `t4_r23h`, `followup_operator_attention`, `drive_signal_type`, `drive_area`, `reply_event_type`, `query_intent`, and `sentiment_intent`). Brain now owns model choice, thresholds, and fallback behaviour centrally instead of each caller carrying its own classifier policy. The patch also fixes packaged headless Guardian map loading: `enforcement_engine` and `agent_runner` now check the installed core directory (`~/.nexo/core/tool-enforcement-map.json`) so followup-runner, morning-agent, digest, and email-monitor load the map instead of falling back to unguarded subprocess execution. Targeted verification: 100 semantic/router/enforcer tests, 125 Drive/cognitive/productization tests, and release-readiness passing.
22
+
23
+ Previously in `7.9.0`: minor release that ships the foundation of the semantic stack (router + reasoner + CLI) under the ONEPASS LLM Coverage plan, plus two product-bug fixes observed in the wild on 2026-04-23. New `src/semantic_router.py` exposes 18 named `decision_kinds` (13 textual + 5 code-aware) with a per-kind policy table and the layer chain `fast_local → semantic_reasoner → remote_fallback`. New `src/semantic_reasoner.py` adds Mode A (`multipass_local`: reuses the mDeBERTa pin with three prompt-perturbed passes + majority vote + 0.75 floor) and Mode B (`cached_llm`: wrapper over `call_model_raw` with a pid+uuid atomic-write 24h-TTL disk cache at `~/.nexo/runtime/operations/semantic-reasoner-cache.json`, SHA-256 keyed by `decision_kind` + normalized input, LRU-bounded at 2000 entries, corrupt entries dropped on read). New `scripts/semantic-classify.py` JSON-in JSON-out CLI lets external MCP clients (including the closed-source NEXO Desktop companion) query Brain as the single semantic authority. New `NEXO_SEMANTIC_REASONER` kill switch (`0`/`off`/`false`/`no`/`disable`/`disabled`) honours the plan mandate for a runtime opt-out separate from `NEXO_LOCAL_CLASSIFIER`. Bug fixes: `bin/nexo-brain.js` upgrade flow now copies `templates/` root the same way fresh install and same-version refresh already did (Maria iMac 7.1.10→7.8.1 upgrade had lost 27 core-prompts templates and broken post-update import verification); and `tool-enforcement-map.json` `nexo_startup.enforcement.inject_prompt` now instructs the model to preload the 13 `mcp__nexo__*` protocol tools via `ToolSearch` before calling `nexo_startup` when the host MCP client defers tool schemas (Claude Code with many MCPs installed). Audit-driven hardening: router/reasoner defensively use `getattr` over the `call_model_raw` module and add a trailing `except Exception` so provider errors degrade with `remote_error` instead of propagating; cache writes use pid+uuid tmp + `fsync` + `os.replace` to survive concurrent writers; `NEXO_SEMANTIC_REASONER_TTL` parse tolerates malformed values. Tests: +50 (22 router, 20 reasoner, 8 CLI). Per-site migration of existing callers (`session_end_intent`, `r14`, `r16`, `r17`, `r20`, `r34`, T4 gates, `tools_drive`, `nexo-followup-runner`) is explicitly deferred to follow-up patch releases and tracked as `NF-SEMANTIC-ROUTER-SITE-MIGRATION`; nothing in this release changes the behaviour of the existing callers. Companion coordinated release: NEXO Desktop v0.28.0.
22
24
 
23
25
  Previously in `7.8.2`: patch release that fixes the compact-hook observability gap Francisco flagged after v7.8.1: `hook_runs.session_id` was empty for 7 out of 8 recent compaction rows (and when populated it stored the raw Claude Code token instead of the NEXO sid), so per-session queries over `hook_runs` for compact events could not be joined back to the NEXO session that actually compacted. v7.8.2 adds `src/hooks/compact_session_resolver.py` with `resolve_nexo_sid(claude_session_id)`, which walks the same rails the shell already uses: `sessions.claude_session_id` match, then `session_claude_aliases.claude_session_id` (most recent `last_seen` wins), then the per-conversation sidecar under `runtime/data/compacting/<safe-claude-id>.txt`, then the legacy global sidecar for single-conversation setups. `src/hooks/pre_compact.py` and `src/hooks/post_compact.py` now call the resolver and store the real NEXO sid in `hook_runs.session_id`; both wrappers also stash `{claude_session_id, sid_source}` in `hook_runs.metadata` so "why is this row still empty?" has a one-query answer. Nine new tests in `tests/test_hook_runs_compact_sid_resolution.py` pin the five resolver rails (sessions / alias / sidecar / legacy / none), malformed-sidecar rejection, the pre- and post-compact wrapper end-to-end paths, and the empty-state wrapper rail so a clean audit trail is written even when nothing resolves. No Desktop bump.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.0",
3
+ "version": "7.9.2",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain \u2014 Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -792,6 +792,7 @@ def _build_enforcement_system_prompt() -> str:
792
792
  map_path = NEXO_HOME / "tool-enforcement-map.json"
793
793
  if not map_path.exists():
794
794
  for candidate in [
795
+ Path(__file__).parent / "tool-enforcement-map.json",
795
796
  Path(__file__).parent.parent / "tool-enforcement-map.json",
796
797
  ]:
797
798
  if candidate.exists():
@@ -39,6 +39,7 @@ from core_prompts import render_core_prompt
39
39
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
40
40
  STATE_PATH = NEXO_HOME / "runtime" / "data" / "autonomy_mandate.json"
41
41
  CLASSIFIER_QUESTION = render_core_prompt("autonomy-mandate-question")
42
+ SEMANTIC_LABELS = ("autonomy_mandate", "not_mandate")
42
43
 
43
44
  # Marker list per NF-DS-45569A27. Case-insensitive substring match.
44
45
  MARKERS = (
@@ -119,9 +120,21 @@ def _detect_marker(text: str, *, classifier=None) -> Optional[str]:
119
120
  return marker
120
121
  if classifier is None:
121
122
  try:
122
- from enforcement_classifier import classify as classifier # type: ignore
123
+ from semantic_router import route as semantic_route
123
124
  except Exception:
124
125
  return None
126
+ try:
127
+ result = semantic_route(
128
+ decision_kind="autonomy_mandate",
129
+ question=CLASSIFIER_QUESTION,
130
+ context=text.strip()[:1200],
131
+ labels=SEMANTIC_LABELS,
132
+ )
133
+ if bool(result.ok and (result.label or result.verdict) == "autonomy_mandate"):
134
+ return _SEMANTIC_MARKER
135
+ except Exception:
136
+ return None
137
+ return None
125
138
  try:
126
139
  if bool(classifier(question=CLASSIFIER_QUESTION, context=text.strip()[:1200])):
127
140
  return _SEMANTIC_MARKER
@@ -27,7 +27,6 @@ _QUERY_INTENT_LOCAL_CONFIDENCE_THRESHOLD = float(
27
27
  _QUERY_INTENT_CACHE_TTL_SECONDS = int(
28
28
  os.environ.get("NEXO_QUERY_INTENT_LOCAL_CACHE_TTL", "21600")
29
29
  )
30
- _LOCAL_QUERY_INTENT_CLASSIFIER = None
31
30
  _QUERY_INTENT_CACHE: dict[str, dict] = {}
32
31
  _QUERY_INTENT_LABELS = (
33
32
  ("A how-to guide, procedure, or step-by-step instruction request", "howto"),
@@ -75,27 +74,27 @@ def _local_classify_query_intent(query: str) -> dict:
75
74
  if cached and cached.get("expires_at", 0) > time.time():
76
75
  return {k: v for k, v in cached.items() if k != "expires_at"}
77
76
 
78
- global _LOCAL_QUERY_INTENT_CLASSIFIER
79
77
  try:
80
- if _LOCAL_QUERY_INTENT_CLASSIFIER is None:
81
- from classifier_local import LocalZeroShotClassifier
82
-
83
- _LOCAL_QUERY_INTENT_CLASSIFIER = LocalZeroShotClassifier(
84
- confidence_floor=_QUERY_INTENT_LOCAL_CONFIDENCE_THRESHOLD,
85
- )
86
- if not _LOCAL_QUERY_INTENT_CLASSIFIER.is_available():
87
- return {"available": False, "label": None, "reason": "classifier_unavailable"}
78
+ from semantic_router import route as semantic_route
79
+ except Exception as exc:
80
+ return {"available": False, "label": None, "reason": f"router_unavailable:{exc}"}
81
+ try:
88
82
  label_texts = [label for label, _intent in _QUERY_INTENT_LABELS]
89
83
  intent_by_label = {label: intent for label, intent in _QUERY_INTENT_LABELS}
90
- result = _LOCAL_QUERY_INTENT_CLASSIFIER.classify(query, label_texts)
91
- if result is None:
92
- return {"available": False, "label": None, "reason": "classifier_failed"}
84
+ result = semantic_route(
85
+ decision_kind="query_intent",
86
+ question="Classify the user's search query intent.",
87
+ context=query,
88
+ labels=tuple(label_texts),
89
+ )
90
+ if not result.ok:
91
+ return {"available": False, "label": None, "reason": result.error or "router_no_route"}
93
92
  intent = intent_by_label.get(result.label)
94
93
  payload = {
95
94
  "available": intent is not None,
96
95
  "label": intent,
97
96
  "confidence": float(result.confidence or 0.0),
98
- "reason": "local_zero_shot",
97
+ "reason": result.route_used,
99
98
  }
100
99
  _QUERY_INTENT_CACHE[key] = {
101
100
  **payload,
@@ -103,7 +102,7 @@ def _local_classify_query_intent(query: str) -> dict:
103
102
  }
104
103
  return payload
105
104
  except Exception as exc:
106
- return {"available": False, "label": None, "reason": f"classifier_error:{exc}"}
105
+ return {"available": False, "label": None, "reason": f"router_error:{exc}"}
107
106
 
108
107
  def bm25_search(query_text: str, stores: str = "both", top_k: int = 20,
109
108
  source_type_filter: str = "") -> list[dict]:
@@ -274,7 +274,6 @@ _LOCAL_SENTIMENT_CONFIDENCE_THRESHOLD = float(
274
274
  _LOCAL_SENTIMENT_CACHE_TTL_SECONDS = int(
275
275
  os.environ.get("NEXO_SENTIMENT_LOCAL_CACHE_TTL", "21600")
276
276
  )
277
- _LOCAL_SENTIMENT_CLASSIFIER = None
278
277
  _LOCAL_SENTIMENT_CACHE: dict[str, dict] = {}
279
278
  _LOCAL_SENTIMENT_LABELS = (
280
279
  ("The user is correcting the assistant or saying the assistant is wrong", "correction"),
@@ -304,27 +303,27 @@ def _local_classify_sentiment_intent(text: str) -> dict:
304
303
  if cached and cached.get("expires_at", 0) > now:
305
304
  return {k: v for k, v in cached.items() if k != "expires_at"}
306
305
 
307
- global _LOCAL_SENTIMENT_CLASSIFIER
308
306
  try:
309
- if _LOCAL_SENTIMENT_CLASSIFIER is None:
310
- from classifier_local import LocalZeroShotClassifier
311
-
312
- _LOCAL_SENTIMENT_CLASSIFIER = LocalZeroShotClassifier(
313
- confidence_floor=_LOCAL_SENTIMENT_CONFIDENCE_THRESHOLD,
314
- )
315
- if not _LOCAL_SENTIMENT_CLASSIFIER.is_available():
316
- return {"available": False, "label": None, "reason": "classifier_unavailable"}
307
+ from semantic_router import route as semantic_route
308
+ except Exception as exc:
309
+ return {"available": False, "label": None, "reason": f"router_unavailable:{exc}"}
310
+ try:
317
311
  label_texts = [label for label, _intent in _LOCAL_SENTIMENT_LABELS]
318
312
  intent_by_label = {label: intent for label, intent in _LOCAL_SENTIMENT_LABELS}
319
- result = _LOCAL_SENTIMENT_CLASSIFIER.classify(text, label_texts)
320
- if result is None:
321
- return {"available": False, "label": None, "reason": "classifier_failed"}
313
+ result = semantic_route(
314
+ decision_kind="sentiment_intent",
315
+ question="Classify the operator message sentiment intent.",
316
+ context=text,
317
+ labels=tuple(label_texts),
318
+ )
319
+ if not result.ok:
320
+ return {"available": False, "label": None, "reason": result.error or "router_no_route"}
322
321
  intent = intent_by_label.get(result.label)
323
322
  payload = {
324
323
  "available": intent in SENTIMENT_INTENTS,
325
324
  "label": intent,
326
325
  "confidence": float(result.confidence or 0.0),
327
- "reason": "local_zero_shot",
326
+ "reason": result.route_used,
328
327
  }
329
328
  _LOCAL_SENTIMENT_CACHE[key] = {
330
329
  **payload,
@@ -332,7 +331,7 @@ def _local_classify_sentiment_intent(text: str) -> dict:
332
331
  }
333
332
  return payload
334
333
  except Exception as exc:
335
- return {"available": False, "label": None, "reason": f"classifier_error:{exc}"}
334
+ return {"available": False, "label": None, "reason": f"router_error:{exc}"}
336
335
 
337
336
 
338
337
  def detect_sentiment(text: str) -> dict:
@@ -322,6 +322,7 @@ def _redact_for_log(text: str, max_len: int = 200) -> str:
322
322
 
323
323
  def _load_map() -> dict | None:
324
324
  for candidate in [
325
+ Path(__file__).parent / MAP_FILENAME,
325
326
  paths.home() / MAP_FILENAME,
326
327
  paths.brain_dir() / MAP_FILENAME,
327
328
  paths.legacy_brain_dir() / MAP_FILENAME,
@@ -613,7 +614,7 @@ class HeadlessEnforcer:
613
614
 
614
615
  correction_detector is an injection point for tests. In production it
615
616
  defaults to r14_correction_learning.detect_correction which routes
616
- through enforcement_classifier + call_model_raw. Fail-closed: a
617
+ through semantic_router. Fail-closed: a
617
618
  broken classifier keeps the window closed (no false positives).
618
619
  """
619
620
  self.user_message_count += 1
@@ -1134,7 +1135,6 @@ class HeadlessEnforcer:
1134
1135
  self._t4_gate_warned = set()
1135
1136
  try:
1136
1137
  from t4_llm_gate import build_prompt, classify_with_llm
1137
- from enforcement_classifier import classify as _classifier_raw
1138
1138
  except Exception as exc:
1139
1139
  key = (rule_id, f"import:{exc.__class__.__name__}")
1140
1140
  if key not in self._t4_gate_warned:
@@ -1147,15 +1147,40 @@ class HeadlessEnforcer:
1147
1147
  )
1148
1148
  return False
1149
1149
 
1150
- # Auditor H1 fix: the legacy bool contract of `classify` collapses
1151
- # "classifier said no" and "classifier response unparseable after
1152
- # two retries — conservative fallback" into the same False, which
1153
- # would silently suppress destructive rules (R23e/R23f/R23h) when
1154
- # the backend responds with garbage. Force the tristate path so
1155
- # "unknown" falls through to regex behaviour instead of becoming
1156
- # a silent rule disable.
1150
+ # Auditor H1 invariant: only an explicit semantic "no" may suppress
1151
+ # a regex hit. Router unavailable/no_route/ambiguous answers fall
1152
+ # through as "unknown" so the original rule still protects us.
1157
1153
  def _classifier_tristate(q: str, ctx: str) -> str:
1158
- return _classifier_raw(q, ctx, tristate=True)
1154
+ decision_kind_by_rule = {
1155
+ "R15": "t4_r15",
1156
+ "R23e": "t4_r23e",
1157
+ "R23f": "t4_r23f",
1158
+ "R23h": "t4_r23h",
1159
+ }
1160
+ decision_kind = decision_kind_by_rule.get(rule_id)
1161
+ if not decision_kind:
1162
+ return "unknown"
1163
+ try:
1164
+ from semantic_router import route as semantic_route
1165
+ except Exception:
1166
+ return "unknown"
1167
+ try:
1168
+ result = semantic_route(
1169
+ decision_kind=decision_kind,
1170
+ question=q,
1171
+ context=ctx,
1172
+ labels=("rule_applies", "false_positive"),
1173
+ )
1174
+ except Exception:
1175
+ return "unknown"
1176
+ if not result.ok:
1177
+ return "unknown"
1178
+ label = result.label or result.verdict
1179
+ if label == "rule_applies":
1180
+ return "yes"
1181
+ if label == "false_positive":
1182
+ return "no"
1183
+ return "unknown"
1159
1184
 
1160
1185
  prompt = build_prompt(rule_id, span=span, context=context)
1161
1186
  if not prompt:
@@ -1885,6 +1910,31 @@ class HeadlessEnforcer:
1885
1910
  if mode == "off":
1886
1911
  return
1887
1912
  recent_names = [r.tool for r in self.recent_tool_records]
1913
+ if classifier is None:
1914
+ def _semantic_classifier(question: str, context: str):
1915
+ try:
1916
+ from semantic_router import route as semantic_route
1917
+ except Exception:
1918
+ return "unknown"
1919
+ try:
1920
+ result = semantic_route(
1921
+ decision_kind="r34_identity_coherence",
1922
+ question=question,
1923
+ context=context,
1924
+ labels=("past_action_denial", "not_a_denial"),
1925
+ )
1926
+ except Exception:
1927
+ return "unknown"
1928
+ if not result.ok:
1929
+ return "unknown"
1930
+ label = result.label or result.verdict
1931
+ if label == "past_action_denial":
1932
+ return "yes"
1933
+ if label == "not_a_denial":
1934
+ return "no"
1935
+ return "unknown"
1936
+
1937
+ classifier = _semantic_classifier
1888
1938
  try:
1889
1939
  inject, prompt, matched = _r34_should(
1890
1940
  text, recent_tool_names=recent_names, classifier=classifier,
@@ -10,6 +10,7 @@ from core_prompts import render_core_prompt
10
10
 
11
11
 
12
12
  CLASSIFIER_QUESTION = render_core_prompt("guard-verbal-ack-question")
13
+ SEMANTIC_LABELS = ("explicit_ack", "not_ack")
13
14
 
14
15
 
15
16
  def _build_context(
@@ -44,7 +45,7 @@ def detect_guard_verbal_ack(
44
45
  return False
45
46
  if classifier is None:
46
47
  try:
47
- from enforcement_classifier import classify as classifier # type: ignore
48
+ from semantic_router import route as semantic_route
48
49
  except Exception:
49
50
  return False
50
51
  context = _build_context(
@@ -54,6 +55,17 @@ def detect_guard_verbal_ack(
54
55
  file_path=file_path,
55
56
  guard_summary=guard_summary,
56
57
  )
58
+ if classifier is None:
59
+ try:
60
+ result = semantic_route(
61
+ decision_kind="guard_verbal_ack",
62
+ question=CLASSIFIER_QUESTION,
63
+ context=context,
64
+ labels=SEMANTIC_LABELS,
65
+ )
66
+ return bool(result.ok and (result.label or result.verdict) == "explicit_ack")
67
+ except Exception:
68
+ return False
57
69
  try:
58
70
  return bool(classifier(question=CLASSIFIER_QUESTION, context=context))
59
71
  except Exception:
@@ -9,10 +9,9 @@ Fase 2 Protocol Enforcer Fase C (Capa 2) item R14. Plan doc 1 reads:
9
9
 
10
10
  Implementation contract:
11
11
 
12
- - Correction detection goes through the enforcement_classifier
13
- (triple-reinforced yes/no on call_model_raw). Learning #122
14
- prohibits keyword-based semantic detection; the classifier path
15
- is the sanctioned alternative.
12
+ - Correction detection goes through semantic_router decision_kind
13
+ ``r14_correction``. Learning #122 prohibits keyword-based semantic
14
+ detection; the router path is the sanctioned alternative.
16
15
  - Fail-closed: when the classifier is unavailable (no API key,
17
16
  automation_backend=none, timeout, 5xx), is_correction returns
18
17
  False. Downstream R28 (system prompt) and the auto_capture hook
@@ -31,6 +30,8 @@ from __future__ import annotations
31
30
  from core_prompts import render_core_prompt
32
31
 
33
32
  CLASSIFIER_QUESTION = render_core_prompt("r14-correction-learning-question")
33
+ SEMANTIC_LABELS = ("negative_feedback", "ordinary_request")
34
+ POSITIVE_LABEL = "negative_feedback"
34
35
 
35
36
 
36
37
  INJECTION_PROMPT_TEMPLATE = render_core_prompt("r14-correction-learning-injection")
@@ -45,7 +46,7 @@ def detect_correction(user_text: str, *, classifier=None) -> bool:
45
46
  Args:
46
47
  user_text: Raw user-role text from the stream.
47
48
  classifier: Injection point for tests. Defaults to
48
- enforcement_classifier.classify.
49
+ semantic_router.route(decision_kind="r14_correction").
49
50
 
50
51
  Fail-closed on ClassifierUnavailableError — returns False rather
51
52
  than raising so the caller's enforcement loop never crashes on a
@@ -62,7 +63,17 @@ def detect_correction(user_text: str, *, classifier=None) -> bool:
62
63
  return False
63
64
  if classifier is None:
64
65
  try:
65
- from enforcement_classifier import classify as classifier # type: ignore
66
+ from semantic_router import route as semantic_route
67
+ except Exception:
68
+ return False
69
+ try:
70
+ result = semantic_route(
71
+ decision_kind="r14_correction",
72
+ question=CLASSIFIER_QUESTION,
73
+ context=text,
74
+ labels=SEMANTIC_LABELS,
75
+ )
76
+ return bool(result.ok and (result.label or result.verdict) == POSITIVE_LABEL)
66
77
  except Exception:
67
78
  return False
68
79
  try:
@@ -10,9 +10,9 @@ Exposes detect_declared_done(assistant_text, classifier=None) → bool and
10
10
  the reminder prompt template. The window-and-state tracking lives in
11
11
  the HeadlessEnforcer / Desktop EnforcementEngine, not here.
12
12
 
13
- Classifier contract: same triple-reinforced yes/no path as R14
14
- (enforcement_classifier.classify → call_model_raw). Fail-closed on
15
- unavailable backend → detect returns False rather than raising.
13
+ Classifier contract: same semantic_router yes/no path as R14
14
+ (``decision_kind=r16_declared_done``). Fail-closed on unavailable backend →
15
+ detect returns False rather than raising.
16
16
 
17
17
  Mirror: nexo-desktop/lib/r16-declared-done.js (pending, landing in the
18
18
  next tranche alongside the JS classifier infrastructure).
@@ -22,6 +22,7 @@ from __future__ import annotations
22
22
  from core_prompts import render_core_prompt
23
23
 
24
24
  CLASSIFIER_QUESTION = render_core_prompt("r16-declared-done-question")
25
+ SEMANTIC_LABELS = ("declared_done", "not_done")
25
26
 
26
27
 
27
28
  INJECTION_PROMPT_TEMPLATE = render_core_prompt("r16-declared-done-injection")
@@ -43,7 +44,17 @@ def detect_declared_done(assistant_text: str, *, classifier=None) -> bool:
43
44
  return False
44
45
  if classifier is None:
45
46
  try:
46
- from enforcement_classifier import classify as classifier # type: ignore
47
+ from semantic_router import route as semantic_route
48
+ except Exception:
49
+ return False
50
+ try:
51
+ result = semantic_route(
52
+ decision_kind="r16_declared_done",
53
+ question=CLASSIFIER_QUESTION,
54
+ context=text,
55
+ labels=SEMANTIC_LABELS,
56
+ )
57
+ return bool(result.ok and (result.label or result.verdict) == "declared_done")
47
58
  except Exception:
48
59
  return False
49
60
  try:
@@ -9,9 +9,9 @@ Fase 2 Protocol Enforcer Fase D item R17. Plan doc 1 reads:
9
9
  Exposes detect_promise(text, classifier) → bool. State (promise window
10
10
  countdown) lives in the caller — mirrors the R14 / R16 pattern.
11
11
 
12
- Classifier path is the same as R14 / R16: enforcement_classifier.classify
13
- routes through call_model_raw with triple reinforcement. Fail-closed on
14
- any unavailable backend (no promise flagged rather than a false positive).
12
+ Classifier path is the same as R14 / R16:
13
+ semantic_router decision_kind ``r17_promise_debt``. Fail-closed on any
14
+ unavailable backend (no promise flagged rather than a false positive).
15
15
 
16
16
  Mirror: nexo-desktop/lib/r17-promise-debt.js (bundled with Fase D JS
17
17
  twins at the end of the tranche).
@@ -21,6 +21,7 @@ from __future__ import annotations
21
21
  from core_prompts import render_core_prompt
22
22
 
23
23
  CLASSIFIER_QUESTION = render_core_prompt("r17-promise-debt-question")
24
+ SEMANTIC_LABELS = ("promise", "no_promise")
24
25
 
25
26
  INJECTION_PROMPT_TEMPLATE = render_core_prompt("r17-promise-debt-injection")
26
27
 
@@ -37,7 +38,17 @@ def detect_promise(assistant_text: str, *, classifier=None) -> bool:
37
38
  return False
38
39
  if classifier is None:
39
40
  try:
40
- from enforcement_classifier import classify as classifier # type: ignore
41
+ from semantic_router import route as semantic_route
42
+ except Exception:
43
+ return False
44
+ try:
45
+ result = semantic_route(
46
+ decision_kind="r17_promise_debt",
47
+ question=CLASSIFIER_QUESTION,
48
+ context=text,
49
+ labels=SEMANTIC_LABELS,
50
+ )
51
+ return bool(result.ok and (result.label or result.verdict) == "promise")
41
52
  except Exception:
42
53
  return False
43
54
  try:
@@ -9,10 +9,11 @@ Fase 2 Protocol Enforcer Fase D item R20. Plan doc 1 reads:
9
9
  Decision logic is split in two:
10
10
 
11
11
  1. classify_edit_is_constant_change(file_path, new_string, classifier)
12
- → bool. Uses enforcement_classifier over a prompt that asks whether
13
- the edited region looks like a module-level constant, enum member,
14
- configuration key, or shared global (as opposed to a local variable,
15
- helper body, or doc-string edit).
12
+ → bool. Uses semantic_router decision_kind ``r20_constant_change``
13
+ over a prompt that asks whether the edited region looks like a
14
+ module-level constant, enum member, configuration key, or shared
15
+ global (as opposed to a local variable, helper body, or doc-string
16
+ edit).
16
17
 
17
18
  2. recent_grep_covers_symbol(symbol, recent_tool_records) → bool.
18
19
  Structural — walks the recent tool records (same shape as R13) and
@@ -30,6 +31,8 @@ import re
30
31
  from core_prompts import render_core_prompt
31
32
 
32
33
  CLASSIFIER_QUESTION = render_core_prompt("r20-constant-change-question")
34
+ SEMANTIC_LABELS = ("shared_constant_change", "local_or_non_constant_change")
35
+ POSITIVE_LABEL = "shared_constant_change"
33
36
 
34
37
  INJECTION_PROMPT_TEMPLATE = render_core_prompt(
35
38
  "r20-constant-change-injection",
@@ -62,10 +65,21 @@ def classify_edit_is_constant_change(
62
65
  return False
63
66
  if classifier is None:
64
67
  try:
65
- from enforcement_classifier import classify as classifier # type: ignore
68
+ from semantic_router import route as semantic_route
66
69
  except Exception:
67
70
  return False
68
71
  context = f"File: {file_path}\n\nEdited region:\n{new_string[:400]}"
72
+ if classifier is None:
73
+ try:
74
+ result = semantic_route(
75
+ decision_kind="r20_constant_change",
76
+ question=CLASSIFIER_QUESTION,
77
+ context=context,
78
+ labels=SEMANTIC_LABELS,
79
+ )
80
+ return bool(result.ok and (result.label or result.verdict) == POSITIVE_LABEL)
81
+ except Exception:
82
+ return False
69
83
  try:
70
84
  return bool(classifier(question=CLASSIFIER_QUESTION, context=context))
71
85
  except Exception:
@@ -14,7 +14,7 @@ Detection strategy (two layers):
14
14
  positive rate (any agent message saying "I haven't done X" today
15
15
  would match even when the action is plainly something NEXO has not
16
16
  done).
17
- 2. LLM classifier confirmation (shared with T4). When the regex fires
17
+ 2. Semantic router confirmation. When the regex fires
18
18
  AND no shared-brain tool has been called this turn, the classifier
19
19
  decides whether the message is really a past-tense denial worth
20
20
  nudging. Tests use a fake classifier to avoid hitting the SDK.
@@ -34,8 +34,8 @@ from core_prompts import render_core_prompt
34
34
  def _verdict_to_bool(verdict: Any) -> bool:
35
35
  """Normalize a classifier verdict to an inject/no-inject decision.
36
36
 
37
- The shared ``enforcement_classifier.classify`` exposes a ``tristate``
38
- mode that returns ``"yes"``/``"no"``/``"unknown"`` strings. A naive
37
+ The engine-level semantic router adapter returns ``"yes"``/``"no"``/
38
+ ``"unknown"`` strings. A naive
39
39
  ``bool(verdict)`` wrap treats ``"unknown"`` (and any non-empty string
40
40
  the model may produce) as truthy, which is fail-OPEN for R34. Only
41
41
  a real ``True`` or an explicit ``"yes"`` should trigger an injection;
@@ -122,10 +122,9 @@ def should_inject_r34(
122
122
  return False, "", ""
123
123
  if classifier is None:
124
124
  return True, INJECTION_PROMPT, matched
125
- # LLM disambiguation — reuses T4 infra. The engine passes a lambda
126
- # that calls enforcement_classifier.classify under the hood. Parse
127
- # the verdict via _verdict_to_bool so tristate "unknown" does not
128
- # coerce to True.
125
+ # Semantic disambiguation — the engine passes a lambda that routes
126
+ # through semantic_router. Parse the verdict via _verdict_to_bool so
127
+ # tristate "unknown" does not coerce to True.
129
128
  try:
130
129
  raw_verdict = classifier(CLASSIFIER_QUESTION, message)
131
130
  except Exception:
@@ -159,30 +159,8 @@ def _classifier_requires_operator_attention(text: str, operator_name: str = "")
159
159
  clean_text = " ".join(str(text or "").split())
160
160
  if len(clean_text) < 12:
161
161
  return None
162
- try:
163
- from classifier_local import LocalZeroShotClassifier
164
- except Exception:
165
- return None
166
-
167
- classifier = LocalZeroShotClassifier(confidence_floor=0.72)
168
- if not classifier.is_available():
169
- return None
170
162
 
171
163
  needs_attention, can_continue, waiting_external = _operator_attention_label_set(operator_name)
172
- result = classifier.classify(clean_text, labels=(needs_attention, can_continue, waiting_external))
173
- if result is None or result.confidence < 0.72:
174
- return None
175
- if result.label == needs_attention:
176
- return True
177
- if result.label in {can_continue, waiting_external}:
178
- return False
179
- return None
180
-
181
-
182
- def _llm_requires_operator_attention(text: str, operator_name: str = "") -> bool | None:
183
- clean_text = " ".join(str(text or "").split())
184
- if len(clean_text) < 24:
185
- return None
186
164
  clean_operator = " ".join(str(operator_name or "").split()).strip()
187
165
  subject = clean_operator if clean_operator else "the operator"
188
166
  question = render_core_prompt(
@@ -194,22 +172,32 @@ def _llm_requires_operator_attention(text: str, operator_name: str = "") -> bool
194
172
  pending_item=clean_text,
195
173
  )
196
174
  try:
197
- from enforcement_classifier import ClassifierUnavailableError, classify
175
+ from semantic_router import route as semantic_route
198
176
  except Exception:
199
177
  return None
200
178
  try:
201
- verdict = classify(question, context=context, tier="muy_bajo", tristate=True)
202
- except ClassifierUnavailableError:
203
- return None
179
+ result = semantic_route(
180
+ decision_kind="followup_operator_attention",
181
+ question=question,
182
+ context=context,
183
+ labels=(needs_attention, can_continue, waiting_external),
184
+ )
204
185
  except Exception:
205
186
  return None
206
- if verdict == "yes":
187
+ if not result.ok:
188
+ return None
189
+ label = result.label or result.verdict
190
+ if label == needs_attention:
207
191
  return True
208
- if verdict == "no":
192
+ if label in {can_continue, waiting_external}:
209
193
  return False
210
194
  return None
211
195
 
212
196
 
197
+ def _llm_requires_operator_attention(text: str, operator_name: str = "") -> bool | None:
198
+ return _classifier_requires_operator_attention(text, operator_name=operator_name)
199
+
200
+
213
201
  def _followup_needs_operator_attention(followup: dict, operator_name: str = "") -> bool:
214
202
  status = str(followup.get("status") or "").strip().lower()
215
203
  owner = str(followup.get("owner") or "").strip().lower()
@@ -94,7 +94,6 @@ RESOLUTION_PATTERNS = (
94
94
  )
95
95
 
96
96
  _REPLY_EVENT_CONFIDENCE = float(os.environ.get("NEXO_REPLY_EVENT_CONFIDENCE", "0.72"))
97
- _LOCAL_REPLY_EVENT_CLASSIFIER = None
98
97
  _REPLY_EVENT_LABELS = (
99
98
  ("The reply acknowledges receipt or says the work starts now", "ack"),
100
99
  ("The reply makes a future commitment or promises an update later", "commitment"),
@@ -180,20 +179,20 @@ def _classify_reply_event_semantically(body_text):
180
179
  if len(text) < 20:
181
180
  return None
182
181
 
183
- global _LOCAL_REPLY_EVENT_CLASSIFIER
184
182
  try:
185
- if _LOCAL_REPLY_EVENT_CLASSIFIER is None:
186
- from classifier_local import LocalZeroShotClassifier
187
-
188
- _LOCAL_REPLY_EVENT_CLASSIFIER = LocalZeroShotClassifier(
189
- confidence_floor=_REPLY_EVENT_CONFIDENCE,
190
- )
191
- if not _LOCAL_REPLY_EVENT_CLASSIFIER.is_available():
192
- return None
183
+ from semantic_router import route as semantic_route
184
+ except Exception:
185
+ return None
186
+ try:
193
187
  label_texts = [label for label, _canonical in _REPLY_EVENT_LABELS]
194
188
  canonical_by_label = {label: canonical for label, canonical in _REPLY_EVENT_LABELS}
195
- result = _LOCAL_REPLY_EVENT_CLASSIFIER.classify(text, label_texts)
196
- if result is None:
189
+ result = semantic_route(
190
+ decision_kind="reply_event_type",
191
+ question="Classify the email reply lifecycle event.",
192
+ context=text,
193
+ labels=tuple(label_texts),
194
+ )
195
+ if not result.ok:
197
196
  return None
198
197
  if float(result.confidence or 0.0) < _REPLY_EVENT_CONFIDENCE:
199
198
  return None
@@ -143,6 +143,7 @@ def _reason_multipass_local(
143
143
  *,
144
144
  decision_kind: str,
145
145
  question: str,
146
+ context: str = "",
146
147
  labels: tuple[str, ...] | None,
147
148
  confidence_floor: float,
148
149
  ):
@@ -156,7 +157,8 @@ def _reason_multipass_local(
156
157
  error="multipass_local requires labels",
157
158
  )
158
159
 
159
- votes = _collect_local_votes(question, labels)
160
+ semantic_input = (context or "").strip() or question
161
+ votes = _collect_local_votes(semantic_input, labels)
160
162
  label, confidence, meta = _aggregate_votes(votes, confidence_floor)
161
163
  if label is None:
162
164
  return RouterResult(
@@ -557,6 +559,7 @@ def reason(
557
559
  return _reason_multipass_local(
558
560
  decision_kind=decision_kind,
559
561
  question=question,
562
+ context=context,
560
563
  labels=labels_tuple,
561
564
  confidence_floor=confidence_floor,
562
565
  )
@@ -173,11 +173,19 @@ def policy_for(decision_kind: str) -> dict[str, Any] | None:
173
173
  def _run_fast_local(
174
174
  *,
175
175
  question: str,
176
+ context: str = "",
176
177
  labels: tuple[str, ...],
177
178
  confidence_floor: float,
178
179
  ) -> RouterResult | None:
179
180
  """Try ``LocalZeroShotClassifier``. Return None on unavailable or
180
- below-threshold so the router advances."""
181
+ below-threshold so the router advances.
182
+
183
+ The first layer must classify the actual user/assistant payload. For
184
+ guard decisions the ``question`` is usually a stable prompt template and
185
+ the live text lives in ``context``; feeding both into a zero-shot NLI
186
+ classifier makes the static prompt dominate the decision. Use context
187
+ when present, and fall back to question for simple direct callers.
188
+ """
181
189
  try:
182
190
  from classifier_local import LocalZeroShotClassifier
183
191
  except Exception as exc: # pragma: no cover — install not ready
@@ -185,7 +193,8 @@ def _run_fast_local(
185
193
  return None
186
194
 
187
195
  clf = LocalZeroShotClassifier(confidence_floor=confidence_floor)
188
- result = clf.classify(question, labels)
196
+ classifier_input = (context or "").strip() or question
197
+ result = clf.classify(classifier_input, labels)
189
198
  if result is None:
190
199
  return None
191
200
  if result.confidence < confidence_floor:
@@ -403,6 +412,7 @@ def route(
403
412
  if policy["fast_local_threshold"] is not None and labels_tuple:
404
413
  fast = _run_fast_local(
405
414
  question=question,
415
+ context=context,
406
416
  labels=labels_tuple,
407
417
  confidence_floor=float(policy["fast_local_threshold"]),
408
418
  )
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
  from core_prompts import render_core_prompt
9
9
 
10
10
  CLASSIFIER_QUESTION = render_core_prompt("session-end-intent-question")
11
+ SEMANTIC_LABELS = ("session_end", "continue_session")
11
12
 
12
13
 
13
14
  def detect_session_end_intent(user_text: str, *, classifier=None) -> bool:
@@ -16,7 +17,17 @@ def detect_session_end_intent(user_text: str, *, classifier=None) -> bool:
16
17
  return False
17
18
  if classifier is None:
18
19
  try:
19
- from enforcement_classifier import classify as classifier # type: ignore
20
+ from semantic_router import route as semantic_route
21
+ except Exception:
22
+ return False
23
+ try:
24
+ result = semantic_route(
25
+ decision_kind="session_end_intent",
26
+ question=CLASSIFIER_QUESTION,
27
+ context=text,
28
+ labels=SEMANTIC_LABELS,
29
+ )
30
+ return bool(result.ok and (result.label or result.verdict) == "session_end")
20
31
  except Exception:
21
32
  return False
22
33
  try:
@@ -8,7 +8,6 @@ heartbeat, task_close, and diary consolidation.
8
8
  import json
9
9
  import os
10
10
  import re
11
- import subprocess
12
11
  import time
13
12
  import unicodedata
14
13
  from core_prompts import render_core_prompt
@@ -30,29 +29,14 @@ _SEMANTIC_THRESHOLD = 0.75
30
29
  _SEMANTIC_MARGIN = 0.15
31
30
  _LLM_MIN_TEXT_CHARS = int(os.environ.get("NEXO_DRIVE_LLM_MIN_CHARS", "24"))
32
31
  _LOCAL_CONFIDENCE_THRESHOLD = float(os.environ.get("NEXO_DRIVE_LOCAL_CONFIDENCE", "0.72"))
33
- _LLM_TIMEOUT_SECONDS = int(os.environ.get("NEXO_DRIVE_LLM_TIMEOUT", "20"))
34
32
  _LLM_CONFIDENCE_THRESHOLD = float(os.environ.get("NEXO_DRIVE_LLM_CONFIDENCE", "0.62"))
35
33
  _LLM_CACHE_TTL_SECONDS = int(os.environ.get("NEXO_DRIVE_LLM_CACHE_TTL", "21600"))
36
- _LLM_ALLOWED_LABELS = {"anomaly", "pattern", "gap", "opportunity", "none"}
37
34
  _LLM_CLASSIFICATION_CACHE: dict[str, dict] = {}
38
- _AREA_LLM_ALLOWED_LABELS = {
39
- "shopify",
40
- "google-ads",
41
- "meta-ads",
42
- "wazion",
43
- "nexo",
44
- "canaririural",
45
- "seo",
46
- "email",
47
- "none",
48
- }
49
35
  _LLM_AREA_CLASSIFICATION_CACHE: dict[str, dict] = {}
50
36
  _LOCAL_ALLOWED_LABELS = ("anomaly", "pattern", "gap", "opportunity", "none")
51
- _LOCAL_SIGNAL_CLASSIFIER = None
52
37
  _AREA_SCORE_THRESHOLD = 0.64
53
38
  _AREA_SCORE_MARGIN = 0.14
54
39
  _AREA_LOCAL_CONFIDENCE_THRESHOLD = float(os.environ.get("NEXO_DRIVE_AREA_LOCAL_CONFIDENCE", "0.66"))
55
- _LOCAL_AREA_CLASSIFIER = None
56
40
 
57
41
  _SIGNAL_CUES = {
58
42
  "anomaly": {
@@ -323,29 +307,29 @@ def _local_classify_signal(text: str) -> dict:
323
307
  if len(text_norm) < _LLM_MIN_TEXT_CHARS:
324
308
  return {"available": False, "label": None, "reason": "text_too_short"}
325
309
 
326
- global _LOCAL_SIGNAL_CLASSIFIER
327
310
  try:
328
- if _LOCAL_SIGNAL_CLASSIFIER is None:
329
- from classifier_local import LocalZeroShotClassifier
330
-
331
- _LOCAL_SIGNAL_CLASSIFIER = LocalZeroShotClassifier(
332
- confidence_floor=_LOCAL_CONFIDENCE_THRESHOLD,
333
- )
334
- if not _LOCAL_SIGNAL_CLASSIFIER.is_available():
335
- return {"available": False, "label": None, "reason": "classifier_unavailable"}
336
- result = _LOCAL_SIGNAL_CLASSIFIER.classify(text, _LOCAL_ALLOWED_LABELS)
337
- if result is None:
338
- return {"available": False, "label": None, "reason": "classifier_failed"}
311
+ from semantic_router import route as semantic_route
312
+ except Exception as exc:
313
+ return {"available": False, "label": None, "reason": f"router_unavailable:{exc}"}
314
+ try:
315
+ result = semantic_route(
316
+ decision_kind="drive_signal_type",
317
+ question=render_core_prompt("drive-signal-classifier-system"),
318
+ context=text.strip()[:3000],
319
+ labels=_LOCAL_ALLOWED_LABELS,
320
+ )
321
+ if not result.ok:
322
+ return {"available": False, "label": None, "reason": result.error or "router_no_route"}
339
323
  label = result.label if result.label in _LOCAL_ALLOWED_LABELS else None
340
324
  return {
341
325
  "available": label is not None,
342
326
  "label": None if label == "none" else label,
343
327
  "confidence": float(result.confidence or 0.0),
344
- "reason": "local_zero_shot",
345
- "source": "local",
328
+ "reason": result.route_used,
329
+ "source": "semantic_router",
346
330
  }
347
331
  except Exception as exc:
348
- return {"available": False, "label": None, "reason": f"classifier_error:{exc}"}
332
+ return {"available": False, "label": None, "reason": f"router_error:{exc}"}
349
333
 
350
334
 
351
335
  def _llm_classify_signal(text: str) -> dict:
@@ -359,54 +343,7 @@ def _llm_classify_signal(text: str) -> dict:
359
343
  if cached and cached.get("expires_at", 0) > now:
360
344
  return {k: v for k, v in cached.items() if k != "expires_at"}
361
345
 
362
- try:
363
- from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
364
- except Exception as exc:
365
- return {"available": False, "label": None, "reason": f"runner_unavailable:{exc}"}
366
-
367
- json_system_prompt = render_core_prompt("drive-signal-classifier-system")
368
- prompt = render_core_prompt(
369
- "drive-signal-classifier-user",
370
- text=text.strip()[:3000],
371
- )
372
-
373
- try:
374
- result = run_automation_prompt(
375
- prompt,
376
- caller="tools/drive_search",
377
- task_profile="fast",
378
- timeout=_LLM_TIMEOUT_SECONDS,
379
- output_format="text",
380
- append_system_prompt=json_system_prompt,
381
- )
382
- except (AutomationBackendUnavailableError, subprocess.TimeoutExpired) as exc:
383
- return {"available": False, "label": None, "reason": f"automation_unavailable:{exc}"}
384
- except Exception as exc:
385
- return {"available": False, "label": None, "reason": f"automation_error:{exc}"}
386
-
387
- if result.returncode != 0:
388
- return {"available": False, "label": None, "reason": f"automation_returncode:{result.returncode}"}
389
-
390
- parsed = _extract_json_object(result.stdout)
391
- if not parsed:
392
- return {"available": False, "label": None, "reason": "invalid_json"}
393
-
394
- label = str(parsed.get("label", "") or "").strip().lower()
395
- if label not in _LLM_ALLOWED_LABELS:
396
- return {"available": False, "label": None, "reason": "invalid_label"}
397
-
398
- try:
399
- confidence = float(parsed.get("confidence", 0.0) or 0.0)
400
- except (TypeError, ValueError):
401
- confidence = 0.0
402
-
403
- classification = {
404
- "available": True,
405
- "label": None if label == "none" else label,
406
- "confidence": confidence,
407
- "reason": str(parsed.get("reason", "") or ""),
408
- "source": "llm",
409
- }
346
+ classification = _local_classify_signal(text)
410
347
  _LLM_CLASSIFICATION_CACHE[cache_key] = {
411
348
  **classification,
412
349
  "expires_at": now + _LLM_CACHE_TTL_SECONDS,
@@ -425,54 +362,7 @@ def _llm_classify_area(text: str) -> dict:
425
362
  if cached and cached.get("expires_at", 0) > now:
426
363
  return {k: v for k, v in cached.items() if k != "expires_at"}
427
364
 
428
- try:
429
- from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
430
- except Exception as exc:
431
- return {"available": False, "label": None, "reason": f"runner_unavailable:{exc}"}
432
-
433
- json_system_prompt = render_core_prompt("drive-area-classifier-system")
434
- prompt = render_core_prompt(
435
- "drive-area-classifier-user",
436
- text=text.strip()[:3000],
437
- )
438
-
439
- try:
440
- result = run_automation_prompt(
441
- prompt,
442
- caller="tools/drive_area",
443
- task_profile="fast",
444
- timeout=_LLM_TIMEOUT_SECONDS,
445
- output_format="text",
446
- append_system_prompt=json_system_prompt,
447
- )
448
- except (AutomationBackendUnavailableError, subprocess.TimeoutExpired) as exc:
449
- return {"available": False, "label": None, "reason": f"automation_unavailable:{exc}"}
450
- except Exception as exc:
451
- return {"available": False, "label": None, "reason": f"automation_error:{exc}"}
452
-
453
- if result.returncode != 0:
454
- return {"available": False, "label": None, "reason": f"automation_returncode:{result.returncode}"}
455
-
456
- parsed = _extract_json_object(result.stdout)
457
- if not parsed:
458
- return {"available": False, "label": None, "reason": "invalid_json"}
459
-
460
- label = str(parsed.get("label", "") or "").strip().lower()
461
- if label not in _AREA_LLM_ALLOWED_LABELS:
462
- return {"available": False, "label": None, "reason": "invalid_label"}
463
-
464
- try:
465
- confidence = float(parsed.get("confidence", 0.0) or 0.0)
466
- except (TypeError, ValueError):
467
- confidence = 0.0
468
-
469
- classification = {
470
- "available": True,
471
- "label": None if label == "none" else label,
472
- "confidence": confidence,
473
- "reason": str(parsed.get("reason", "") or ""),
474
- "source": "llm",
475
- }
365
+ classification = _local_classify_area(text)
476
366
  _LLM_AREA_CLASSIFICATION_CACHE[cache_key] = {
477
367
  **classification,
478
368
  "expires_at": now + _LLM_CACHE_TTL_SECONDS,
@@ -554,31 +444,31 @@ def _local_classify_area(text: str) -> dict:
554
444
  if len(text_norm) < _LLM_MIN_TEXT_CHARS:
555
445
  return {"available": False, "label": None, "reason": "text_too_short"}
556
446
 
557
- global _LOCAL_AREA_CLASSIFIER
558
447
  try:
559
- if _LOCAL_AREA_CLASSIFIER is None:
560
- from classifier_local import LocalZeroShotClassifier
561
-
562
- _LOCAL_AREA_CLASSIFIER = LocalZeroShotClassifier(
563
- confidence_floor=_AREA_LOCAL_CONFIDENCE_THRESHOLD,
564
- )
565
- if not _LOCAL_AREA_CLASSIFIER.is_available():
566
- return {"available": False, "label": None, "reason": "classifier_unavailable"}
448
+ from semantic_router import route as semantic_route
449
+ except Exception as exc:
450
+ return {"available": False, "label": None, "reason": f"router_unavailable:{exc}"}
451
+ try:
567
452
  label_texts = [label for label, _canonical in _AREA_LOCAL_LABELS]
568
453
  canonical_by_label = {label: canonical for label, canonical in _AREA_LOCAL_LABELS}
569
- result = _LOCAL_AREA_CLASSIFIER.classify(text, label_texts)
570
- if result is None:
571
- return {"available": False, "label": None, "reason": "classifier_failed"}
454
+ result = semantic_route(
455
+ decision_kind="drive_area",
456
+ question=render_core_prompt("drive-area-classifier-system"),
457
+ context=text.strip()[:3000],
458
+ labels=tuple(label_texts),
459
+ )
460
+ if not result.ok:
461
+ return {"available": False, "label": None, "reason": result.error or "router_no_route"}
572
462
  canonical = canonical_by_label.get(result.label)
573
463
  return {
574
464
  "available": canonical is not None,
575
465
  "label": None if canonical == "none" else canonical,
576
466
  "confidence": float(result.confidence or 0.0),
577
- "reason": "local_zero_shot",
578
- "source": "local",
467
+ "reason": result.route_used,
468
+ "source": "semantic_router",
579
469
  }
580
470
  except Exception as exc:
581
- return {"available": False, "label": None, "reason": f"classifier_error:{exc}"}
471
+ return {"available": False, "label": None, "reason": f"router_error:{exc}"}
582
472
 
583
473
 
584
474
  def _legacy_keyword_area(text: str) -> str: