Optional rotation/autoselect override for the internal local models

Each internal-model functionality can now point at a rotation or autoselect
instead of the local model, falling back to the local model when the override is
empty or fails. Per-functionality fields in internal_model (default empty):
condensation_override, autoselect_override, nsfw_classifier_override,
privacy_classifier_override.

- handlers.py: run_meta_target() runs a global rotation/autoselect and returns
  the assistant text (or None -> fallback); classification overrides prompt the
  chat model for a strict YES/NO and parse it, falling back to the local
  classifier; autoselect selection can run through an override.
- context.py: condensation prefers the override, falling back to the local model.
- settings page: per-field override inputs backed by a datalist of available
  rotations/autoselects; saved to internal_model.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent c666aab2
......@@ -61,7 +61,16 @@ class ContextManager:
self._internal_tokenizer = None
self._internal_model_lock = None
self._use_internal_model = False
# Global override: a rotation/autoselect to use for condensation instead of
# the local model (falls back to the local model when empty or on failure).
try:
_aisbf = config.get_aisbf_config()
self._condensation_override = ((_aisbf.internal_model or {}).get('condensation_override') or '').strip() if _aisbf else ''
except Exception:
self._condensation_override = ''
# Get max_context for condensation model
self.condensation_max_context = None
if self.condensation_config and hasattr(self.condensation_config, 'max_context'):
......@@ -329,6 +338,21 @@ class ContextManager:
return head_text + summary_block + tail_text
async def _run_local_or_override_condensation(self, messages: list, max_tokens: int, temperature: float) -> str:
"""Run condensation via the global rotation/autoselect override when set,
falling back to the local internal model on empty/failed override."""
if self._condensation_override:
import logging
logger = logging.getLogger(__name__)
from .handlers import run_meta_target
content = await run_meta_target(
self._condensation_override, messages, max_tokens=max_tokens, temperature=temperature
)
if content:
return content
logger.warning(f"Condensation override '{self._condensation_override}' unavailable; falling back to local model")
return await self._run_internal_model_condensation(messages)
async def _run_internal_model_condensation(self, messages: list) -> str:
"""Run the internal model for condensation in a separate thread"""
import logging
......@@ -629,8 +653,10 @@ class ContextManager:
summary_content = None
if self._use_internal_model:
logger.info("Using internal model for conversational summarization")
summary_content = await self._run_internal_model_condensation(condensation_messages)
logger.info("Using internal model (or override) for conversational summarization")
summary_content = await self._run_local_or_override_condensation(
condensation_messages, self._get_condensation_max_tokens(), 0.3
)
elif self._rotation_handler and not self.condensation_model:
# Use rotation handler
condensation_request = {
......@@ -733,8 +759,10 @@ class ContextManager:
pruned_content = None
if self._use_internal_model:
logger.info("Using internal model for semantic pruning")
pruned_content = await self._run_internal_model_condensation(condensation_messages)
logger.info("Using internal model (or override) for semantic pruning")
pruned_content = await self._run_local_or_override_condensation(
condensation_messages, 2000, 0.2
)
elif self._rotation_handler and not self.condensation_model:
# Use rotation handler
condensation_request = {
......
......@@ -2442,6 +2442,121 @@ _rotation_round_robin: Dict[str, int] = {}
_rotation_round_robin_lock = threading.Lock()
async def run_meta_target(target_id, messages, max_tokens=None, temperature=0.0, stop=None):
"""Run a GLOBAL rotation or autoselect as an override for an internal/local model.
Used by the internal-model overrides (compaction, classification, autoselect
selection). Resolves ``target_id`` against the global rotations/autoselects,
forwards a non-streaming chat request, and returns the assistant message
content string — or ``None`` on any failure so callers can fall back to their
local model.
"""
import logging
logger = logging.getLogger(__name__)
target_id = (target_id or '').strip()
if not target_id:
return None
is_rotation = bool(getattr(config, 'rotations', None)) and target_id in config.rotations
is_autoselect = bool(getattr(config, 'autoselect', None)) and target_id in config.autoselect
if not is_rotation and not is_autoselect:
logger.warning(f"Internal-model override target '{target_id}' is neither a rotation nor an autoselect; using local model")
return None
request_data = {
'model': target_id,
'messages': messages,
'stream': False,
'temperature': temperature,
}
if max_tokens:
request_data['max_tokens'] = max_tokens
if stop:
request_data['stop'] = stop
try:
if is_rotation:
result = await RotationHandler().handle_rotation_request(target_id, request_data)
else:
result = await AutoselectHandler().handle_autoselect_request(target_id, request_data)
except Exception as e:
logger.warning(f"Internal-model override '{target_id}' failed: {e}")
return None
if not isinstance(result, dict) or result.get('aisbf_error'):
logger.warning(f"Internal-model override '{target_id}' returned no usable response")
return None
try:
return result['choices'][0]['message']['content']
except Exception:
return None
async def classify_with_override(target_id, text, kind):
"""Classify ``text`` with a chat rotation/autoselect override.
``kind`` is 'nsfw' or 'privacy'. Returns True if the content is FLAGGED
(unsafe), False if safe, or None when the override is unavailable/unparseable
so the caller can fall back to the local classifier pipeline.
"""
target_id = (target_id or '').strip()
if not target_id:
return None
text_to_check = text[:512] if len(text) > 512 else text
if kind == 'nsfw':
question = "Does the following text contain NSFW content (sexual, explicit, or adult material)?"
else:
question = ("Does the following text contain privacy-sensitive personal information (PII) such as "
"real names, addresses, emails, phone numbers, or financial/identity data?")
messages = [
{"role": "system", "content": "You are a strict binary content classifier. Reply with exactly one word: YES or NO. Do not explain."},
{"role": "user", "content": f"{question}\n\nTEXT:\n{text_to_check}\n\nAnswer YES or NO."},
]
content = await run_meta_target(target_id, messages, max_tokens=5, temperature=0.0)
if not content:
return None
ans = content.strip().upper()
if ans.startswith('YES'):
return True
if ans.startswith('NO'):
return False
has_yes, has_no = ('YES' in ans), ('NO' in ans)
if has_yes and not has_no:
return True
if has_no and not has_yes:
return False
return None # unparseable → fall back to local classifier
async def classify_content_with_overrides(text, check_nsfw, check_privacy, aisbf_config):
"""Run NSFW/privacy checks, preferring per-functionality rotation/autoselect
overrides and falling back to the local classifier pipelines. Returns
(is_safe, message), matching ContentClassifier.check_content."""
internal_model = (aisbf_config.internal_model or {}) if aisbf_config else {}
nsfw_override = internal_model.get('nsfw_classifier_override')
privacy_override = internal_model.get('privacy_classifier_override')
# Ensure local pipelines are available as a fallback for enabled checks.
if (check_nsfw or check_privacy):
content_classifier.initialize(internal_model.get('nsfw_classifier'), internal_model.get('privacy_classifier'))
if check_nsfw:
flagged = await classify_with_override(nsfw_override, text, 'nsfw') if nsfw_override else None
if flagged is None:
is_safe, message = content_classifier.check_nsfw(text)
if not is_safe:
return False, f"NSFW: {message}"
elif flagged:
return False, "NSFW: flagged by override classifier"
if check_privacy:
flagged = await classify_with_override(privacy_override, text, 'privacy') if privacy_override else None
if flagged is None:
is_safe, message = content_classifier.check_privacy(text)
if not is_safe:
return False, f"Privacy: {message}"
elif flagged:
return False, "Privacy: flagged by override classifier"
return True, "All content is safe"
class RotationHandler:
def __init__(self, user_id=None):
self.user_id = user_id
......@@ -3166,20 +3281,16 @@ class RotationHandler:
check_privacy = aisbf_config.classify_privacy
if check_nsfw or check_privacy:
# Initialize classifier with models from config
internal_model_config = aisbf_config.internal_model or {}
nsfw_model = internal_model_config.get('nsfw_classifier')
privacy_model = internal_model_config.get('privacy_classifier')
content_classifier.initialize(nsfw_model, privacy_model)
# Check user prompt for NSFW/privacy content
is_safe, message = content_classifier.check_content(
user_prompt,
check_nsfw=check_nsfw,
check_privacy=check_privacy
# Classify, preferring per-functionality rotation/autoselect overrides
# (internal_model.nsfw_classifier_override / privacy_classifier_override)
# and falling back to the local classifier pipelines.
is_safe, message = await classify_content_with_overrides(
user_prompt,
check_nsfw=check_nsfw,
check_privacy=check_privacy,
aisbf_config=aisbf_config,
)
logger.info(f"Content classification result: {message}")
if not is_safe:
......@@ -5673,6 +5784,26 @@ class AutoselectHandler:
try:
if selection_model == "internal":
aisbf_conf = self.config.get_aisbf_config()
# Global override: route the selection step through a rotation/autoselect
# instead of the local model, falling back to local on any failure.
autoselect_override = (
(aisbf_conf.internal_model or {}).get('autoselect_override', '')
if aisbf_conf else ''
)
if (autoselect_override or '').strip():
logger.info(f"Autoselect selection using override target '{autoselect_override}'")
override_content = await run_meta_target(
autoselect_override, messages, max_tokens=100, temperature=0.0,
stop=["</aisbf_model_autoselection>"]
)
if override_content:
model_id = self._extract_model_selection(override_content)
if model_id:
logger.info(f"=== AUTOSELECT SUCCESS (override) === {model_id}")
return model_id
logger.warning("Could not extract model ID from override response; falling back to local model")
else:
logger.warning(f"Autoselect override '{autoselect_override}' unavailable; falling back to local model")
internal_model_id = (
(aisbf_conf.internal_model or {}).get('autoselect_model_id', '')
if aisbf_conf else ''
......
......@@ -377,6 +377,12 @@ async def dashboard_settings(request: Request):
}
warning = request.query_params.get('warning')
# Available rotations/autoselects for the internal-model override pickers
try:
from aisbf.config import config as _runtime_config
meta_targets = sorted(list((_runtime_config.rotations or {}).keys()) + list((_runtime_config.autoselect or {}).keys()))
except Exception:
meta_targets = []
return _templates.TemplateResponse(
request=request,
name="dashboard/settings.html",
......@@ -385,6 +391,7 @@ async def dashboard_settings(request: Request):
"session": request.session,
"__version__": __version__,
"config": aisbf_config,
"meta_targets": meta_targets,
"os": os,
"warning": warning,
}
......@@ -407,6 +414,10 @@ async def dashboard_settings_save(
nsfw_classifier: str = Form("michelleli99/NSFW_text_classifier"),
privacy_classifier: str = Form("iiiorg/piiranha-v1-detect-personal-information"),
semantic_vectorization: str = Form("sentence-transformers/all-MiniLM-L6-v2"),
condensation_override: str = Form(""),
autoselect_override: str = Form(""),
nsfw_classifier_override: str = Form(""),
privacy_classifier_override: str = Form(""),
classify_nsfw: bool = Form(False),
classify_privacy: bool = Form(False),
classify_semantic: bool = Form(False),
......@@ -715,6 +726,11 @@ async def dashboard_settings_save(
aisbf_config['internal_model']['nsfw_classifier'] = nsfw_classifier
aisbf_config['internal_model']['privacy_classifier'] = privacy_classifier
aisbf_config['internal_model']['semantic_vectorization'] = semantic_vectorization
# Optional rotation/autoselect overrides (empty = use the local model above)
aisbf_config['internal_model']['condensation_override'] = (condensation_override or '').strip()
aisbf_config['internal_model']['autoselect_override'] = (autoselect_override or '').strip()
aisbf_config['internal_model']['nsfw_classifier_override'] = (nsfw_classifier_override or '').strip()
aisbf_config['internal_model']['privacy_classifier_override'] = (privacy_classifier_override or '').strip()
# Update batching config
if 'batching' not in aisbf_config:
......
......@@ -177,6 +177,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="settings-section" id="tab-models">
<div class="section-title"><i class="fas fa-brain"></i> <span data-i18n="settings_page.section_models">Internal Models</span></div>
<datalist id="meta-targets-dl">
{% for t in meta_targets or [] %}<option value="{{ t }}">{% endfor %}
</datalist>
<div class="form-group">
<label for="condensation_model_id" data-i18n="settings_page.lbl_condensation_model">Condensation Model ID</label>
<div style="display:flex; gap:8px; align-items:center;">
......@@ -184,6 +188,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<button type="button" class="btn btn-secondary" style="white-space:nowrap; padding:6px 12px; font-size:.85em;" onclick="clearModelCache(document.getElementById('condensation_model_id').value, 'condensation', this)"><i class="fas fa-trash-alt"></i> Clear Cache</button>
</div>
<small style="color: var(--color-subtle); display: block; margin-top: 5px;">Used when condensation model is set to "internal"</small>
<label for="condensation_override" style="margin-top:8px;">↳ Rotation/Autoselect override <span style="color:var(--color-subtle);font-weight:normal;">(optional — overrides the local model above)</span></label>
<input type="text" id="condensation_override" name="condensation_override" list="meta-targets-dl" value="{{ config.internal_model.condensation_override or '' }}" placeholder="rotation or autoselect name (leave empty to use local model)" style="width:100%;">
</div>
<div class="form-group">
......@@ -199,6 +205,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<button type="button" class="btn btn-secondary" style="white-space:nowrap; padding:6px 12px; font-size:.85em;" onclick="clearModelCache(document.getElementById('autoselect_model_id').value, 'autoselect', this)"><i class="fas fa-trash-alt"></i> Clear Cache</button>
</div>
<small style="color: var(--color-subtle); display: block; margin-top: 5px;">Used when autoselect selection_model is set to "internal"</small>
<label for="autoselect_override" style="margin-top:8px;">↳ Rotation/Autoselect override <span style="color:var(--color-subtle);font-weight:normal;">(optional — overrides the local model above)</span></label>
<input type="text" id="autoselect_override" name="autoselect_override" list="meta-targets-dl" value="{{ config.internal_model.autoselect_override or '' }}" placeholder="rotation or autoselect name (leave empty to use local model)" style="width:100%;">
</div>
<div class="form-group">
......@@ -214,6 +222,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<button type="button" class="btn btn-secondary" style="white-space:nowrap; padding:6px 12px; font-size:.85em;" onclick="clearModelCache(document.getElementById('nsfw_classifier').value, 'nsfw', this)"><i class="fas fa-trash-alt"></i> Clear Cache</button>
</div>
<small style="color: var(--color-subtle); display: block; margin-top: 5px;">Model used for NSFW content detection</small>
<label for="nsfw_classifier_override" style="margin-top:8px;">↳ Rotation/Autoselect override <span style="color:var(--color-subtle);font-weight:normal;">(optional — prompts a chat model for YES/NO, falls back to local)</span></label>
<input type="text" id="nsfw_classifier_override" name="nsfw_classifier_override" list="meta-targets-dl" value="{{ config.internal_model.nsfw_classifier_override or '' }}" placeholder="rotation or autoselect name (leave empty to use local classifier)" style="width:100%;">
</div>
<div class="form-group">
......@@ -223,6 +233,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<button type="button" class="btn btn-secondary" style="white-space:nowrap; padding:6px 12px; font-size:.85em;" onclick="clearModelCache(document.getElementById('privacy_classifier').value, 'privacy', this)"><i class="fas fa-trash-alt"></i> Clear Cache</button>
</div>
<small style="color: var(--color-subtle); display: block; margin-top: 5px;">Model used for privacy-sensitive information detection</small>
<label for="privacy_classifier_override" style="margin-top:8px;">↳ Rotation/Autoselect override <span style="color:var(--color-subtle);font-weight:normal;">(optional — prompts a chat model for YES/NO, falls back to local)</span></label>
<input type="text" id="privacy_classifier_override" name="privacy_classifier_override" list="meta-targets-dl" value="{{ config.internal_model.privacy_classifier_override or '' }}" placeholder="rotation or autoselect name (leave empty to use local classifier)" style="width:100%;">
</div>
<div class="form-group">
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment