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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/agent_runner.py +1 -0
- package/src/autonomy_mandate.py +14 -1
- package/src/cognitive/_search.py +14 -15
- package/src/cognitive/_trust.py +14 -15
- package/src/enforcement_engine.py +60 -10
- package/src/guard_verbal_ack.py +13 -1
- package/src/r14_correction_learning.py +17 -6
- package/src/r16_declared_done.py +15 -4
- package/src/r17_promise_debt.py +15 -4
- package/src/r20_constant_change.py +19 -5
- package/src/r34_identity_coherence.py +6 -7
- package/src/scripts/nexo-followup-runner.py +16 -28
- package/src/scripts/nexo-send-reply.py +11 -12
- package/src/semantic_reasoner.py +4 -1
- package/src/semantic_router.py +12 -2
- package/src/session_end_intent.py +12 -1
- package/src/tools_drive.py +32 -142
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.9.
|
|
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.
|
|
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.
|
|
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",
|
package/src/agent_runner.py
CHANGED
|
@@ -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():
|
package/src/autonomy_mandate.py
CHANGED
|
@@ -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
|
|
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
|
package/src/cognitive/_search.py
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 =
|
|
91
|
-
|
|
92
|
-
|
|
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":
|
|
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"
|
|
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]:
|
package/src/cognitive/_trust.py
CHANGED
|
@@ -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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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 =
|
|
320
|
-
|
|
321
|
-
|
|
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":
|
|
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"
|
|
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
|
|
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
|
|
1151
|
-
#
|
|
1152
|
-
#
|
|
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
|
-
|
|
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,
|
package/src/guard_verbal_ack.py
CHANGED
|
@@ -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
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
package/src/r16_declared_done.py
CHANGED
|
@@ -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
|
|
14
|
-
(
|
|
15
|
-
|
|
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
|
|
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:
|
package/src/r17_promise_debt.py
CHANGED
|
@@ -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:
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
|
13
|
-
the edited region looks like a
|
|
14
|
-
configuration key, or shared
|
|
15
|
-
helper body, or doc-string
|
|
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
|
|
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.
|
|
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
|
|
38
|
-
|
|
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
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
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
|
|
175
|
+
from semantic_router import route as semantic_route
|
|
198
176
|
except Exception:
|
|
199
177
|
return None
|
|
200
178
|
try:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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 =
|
|
196
|
-
|
|
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
|
package/src/semantic_reasoner.py
CHANGED
|
@@ -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
|
-
|
|
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
|
)
|
package/src/semantic_router.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
package/src/tools_drive.py
CHANGED
|
@@ -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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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":
|
|
345
|
-
"source": "
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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 =
|
|
570
|
-
|
|
571
|
-
|
|
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":
|
|
578
|
-
"source": "
|
|
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"
|
|
471
|
+
return {"available": False, "label": None, "reason": f"router_error:{exc}"}
|
|
582
472
|
|
|
583
473
|
|
|
584
474
|
def _legacy_keyword_area(text: str) -> str:
|