fix(admin): label the Classifier card and show what is running vs saved #108

Merged
alee merged 1 commits from fix/admin-classifier-card into main 2026-10-03 05:42:23 +00:00
5 changed files with 207 additions and 32 deletions

View File

@@ -250,6 +250,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
<div class="card-header">
<h3 class="card-title"><span class="me-2" data-icon="cpu"></span>Classifier</h3>
<div class="card-actions d-flex align-items-center gap-2">
<span id="classifier-pending-badge" class="badge bg-warning" style="display:none"></span>
<span id="classifier-mode-badge" class="badge bg-secondary">&mdash;</span>
</div>
</div>
@@ -260,7 +261,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
cascade (stale session &rarr; session history &rarr; <code>cloud_fallback</code> &rarr;
the static guess) unmodified.
</p>
<div class="row g-3 align-items-end">
<div class="row g-3 align-items-start">
<div class="col-md-4">
<label class="form-label" for="classifier-mode-select" style="font-size:.78rem">mode</label>
<select class="form-select form-select-sm" id="classifier-mode-select"
@@ -1142,18 +1143,22 @@ function classifierModeFieldsHtml(mode, data) {
</div>
<div id="classifier-cloud-pinned-fields" class="row g-2" style="${auto ? 'display:none' : ''}">
<div class="col-md-6">
<label class="form-label" for="classifier-cloud-base-url" style="font-size:.78rem">base_url</label>
<input class="form-control form-control-sm" id="classifier-cloud-base-url"
placeholder="base_url" value="${escapeHtml(cp.base_url || '')}">
</div>
<div class="col-md-6">
<label class="form-label" for="classifier-cloud-model" style="font-size:.78rem">model</label>
<input class="form-control form-control-sm" id="classifier-cloud-model"
placeholder="model" value="${escapeHtml(cp.model || '')}">
</div>
<div class="col-md-6">
<label class="form-label" for="classifier-cloud-api-key-env" style="font-size:.78rem">api_key_env</label>
<input class="form-control form-control-sm" id="classifier-cloud-api-key-env"
placeholder="api_key_env (optional)" value="${escapeHtml(cp.api_key_env || '')}">
</div>
<div class="col-md-3">
<label class="form-label" for="classifier-cloud-timeout" style="font-size:.78rem">timeout_seconds</label>
<input class="form-control form-control-sm" type="number" id="classifier-cloud-timeout"
placeholder="timeout_seconds" value="${cp.timeout_seconds != null ? cp.timeout_seconds : ''}">
</div>
@@ -1164,20 +1169,23 @@ function classifierModeFieldsHtml(mode, data) {
return `
<div class="row g-2">
<div class="col-md-6">
<label class="form-label" for="classifier-encoder-model" style="font-size:.78rem">model</label>
<input class="form-control form-control-sm" id="classifier-encoder-model"
placeholder="model (default: BAAI/bge-large-en-v1.5)"
value="${escapeHtml(enc.model || '')}">
</div>
<div class="col-md-3">
<label class="form-label" for="classifier-encoder-device" style="font-size:.78rem">device</label>
<select class="form-select form-select-sm" id="classifier-encoder-device">
<option value="cpu" ${enc.device !== 'cuda' ? 'selected' : ''}>cpu</option>
<option value="cuda" ${enc.device === 'cuda' ? 'selected' : ''}>cuda</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label" for="classifier-encoder-threshold" style="font-size:.78rem">confidence_min</label>
<div class="input-group input-group-sm">
<input class="form-control form-control-sm" type="number" step="1" min="0" max="100"
id="classifier-encoder-threshold" placeholder="confidence % (0-100)"
id="classifier-encoder-threshold" placeholder="0-100"
value="${enc.confidence_min != null ? Math.round(enc.confidence_min * 100) : ''}">
<span class="input-group-text">%</span>
</div>
@@ -1188,38 +1196,50 @@ function classifierModeFieldsHtml(mode, data) {
const dec = (data && data.decision) || {};
return `
<div class="row g-2">
<div class="col-md-4">
<div class="col-md-6">
<label class="form-label" for="classifier-decision-base-url" style="font-size:.78rem"
title="Ollama server the classifier calls">base_url</label>
<input class="form-control form-control-sm" id="classifier-decision-base-url"
data-key="decision.base_url"
placeholder="http://localhost:11434"
value="${escapeHtml(dec.base_url || '')}">
</div>
<div class="col-md-4">
<div class="col-md-6">
<label class="form-label" for="classifier-decision-model" style="font-size:.78rem"
title="Ollama model tag that answers the one-letter question">model</label>
<input class="form-control form-control-sm" id="classifier-decision-model"
data-key="decision.model"
placeholder="qwen3.5:4b"
value="${escapeHtml(dec.model || '')}">
</div>
<div class="col-md-4">
<input class="form-control form-control-sm" type="number" id="classifier-decision-num-ctx"
<div class="col-md-3">
<label class="form-label" for="classifier-decision-num-ctx" style="font-size:.78rem"
title="Context window requested per call, in tokens">num_ctx</label>
<input class="form-control form-control-sm" type="number" min="256" step="256" id="classifier-decision-num-ctx"
data-key="decision.num_ctx"
placeholder="8192"
value="${dec.num_ctx != null ? dec.num_ctx : ''}">
</div>
<div class="col-md-4">
<input class="form-control form-control-sm" type="number" id="classifier-decision-timeout-s"
<div class="col-md-3">
<label class="form-label" for="classifier-decision-timeout-s" style="font-size:.78rem"
title="Per-call timeout in seconds. A hung server costs this much per request until the backoff opens">timeout_s</label>
<input class="form-control form-control-sm" type="number" min="1" step="1" id="classifier-decision-timeout-s"
data-key="decision.timeout_s"
placeholder="10"
value="${dec.timeout_s != null ? dec.timeout_s : ''}">
</div>
<div class="col-md-4">
<input class="form-control form-control-sm" type="number" step="0.1" id="classifier-decision-confidence-min"
<div class="col-md-3">
<label class="form-label" for="classifier-decision-confidence-min" style="font-size:.78rem"
title="Verdicts below this share of the option mass go to the cascade instead (0 to 1)">confidence_min</label>
<input class="form-control form-control-sm" type="number" min="0" max="1" step="0.05" id="classifier-decision-confidence-min"
data-key="decision.confidence_min"
placeholder="0.5"
value="${dec.confidence_min != null ? dec.confidence_min : ''}">
</div>
<div class="col-md-4">
<input class="form-control form-control-sm" type="number" step="0.1" id="classifier-decision-coverage-min"
<div class="col-md-3">
<label class="form-label" for="classifier-decision-coverage-min" style="font-size:.78rem"
title="Minimum total probability on the option letters; below it the model did not answer with a letter (0 to 1)">coverage_min</label>
<input class="form-control form-control-sm" type="number" min="0" max="1" step="0.05" id="classifier-decision-coverage-min"
data-key="decision.coverage_min"
placeholder="0.3"
value="${dec.coverage_min != null ? dec.coverage_min : ''}">
@@ -1331,18 +1351,22 @@ async function loadClassifierConfig() {
document.getElementById('classifier-mode-fields').innerHTML = classifierModeFieldsHtml(data.mode, data);
renderClassifierResolvedPrimary(data);
renderClassifierCandidateCategories(data);
/* The header badge states what is RUNNING, not what is saved: a save only
persists, and the router reads this block at startup, so the two differ
until the next restart. The amber badge is that gap made visible. */
const running = data.running_mode || data.mode;
const badge = document.getElementById('classifier-mode-badge');
badge.textContent = data.mode + (data.mode_source === 'overlay' ? ' (overlay)' : '');
badge.className = data.mode === 'local_llm' ? 'badge bg-secondary' : 'badge bg-info';
if (data.mode === 'local_decision' && data.decision) {
const d = data.decision;
if (d.base_url) document.getElementById('classifier-decision-base-url').value = d.base_url;
if (d.model) document.getElementById('classifier-decision-model').value = d.model;
if (d.num_ctx) document.getElementById('classifier-decision-num-ctx').value = d.num_ctx;
if (d.timeout_s) document.getElementById('classifier-decision-timeout-s').value = d.timeout_s;
if (d.confidence_min) document.getElementById('classifier-decision-confidence-min').value = d.confidence_min;
if (d.coverage_min) document.getElementById('classifier-decision-coverage-min').value = d.coverage_min;
document.getElementById('classifier-decision-tier-enabled').checked = !!d.tier_enabled;
badge.textContent = running + (data.mode_source === 'overlay' && !data.restart_pending ? ' (overlay)' : '');
badge.className = running === 'local_llm' ? 'badge bg-secondary' : 'badge bg-info';
const pending = document.getElementById('classifier-pending-badge');
if (data.restart_pending) {
pending.textContent = data.mode !== running
? `restart pending: ${data.mode}`
: 'restart pending: settings changed';
pending.title = `running ${running}, saved ${data.mode}; the router reads this block at startup`;
pending.style.display = '';
} else {
pending.style.display = 'none';
}
}
@@ -1377,15 +1401,25 @@ function collectClassifierConfigBody() {
if (thresholdPct !== '') encoder.confidence_min = parseFloat(thresholdPct) / 100;
body.encoder = encoder;
} else if (mode === 'local_decision') {
body.decision = {
base_url: document.getElementById('classifier-decision-base-url')?.value || 'http://localhost:11434',
model: document.getElementById('classifier-decision-model')?.value || 'qwen3.5:4b',
num_ctx: parseInt(document.getElementById('classifier-decision-num-ctx')?.value) || 8192,
timeout_s: parseInt(document.getElementById('classifier-decision-timeout-s')?.value) || 10,
confidence_min: parseFloat(document.getElementById('classifier-decision-confidence-min')?.value) || 0.5,
coverage_min: parseFloat(document.getElementById('classifier-decision-coverage-min')?.value) || 0.3,
tier_enabled: document.getElementById('classifier-decision-tier-enabled')?.checked || false,
};
// A blank field is left OUT so the server default applies and keeps floating
// with the repo. Never `value || default`: that rewrites a deliberate 0
// (confidence_min 0 means "accept every verdict") into the default.
const decision = {};
const text = (id) => document.getElementById(id).value.trim();
const baseUrl = text('classifier-decision-base-url');
if (baseUrl !== '') decision.base_url = baseUrl;
const model = text('classifier-decision-model');
if (model !== '') decision.model = model;
const numCtx = text('classifier-decision-num-ctx');
if (numCtx !== '') decision.num_ctx = parseInt(numCtx, 10);
const timeoutS = text('classifier-decision-timeout-s');
if (timeoutS !== '') decision.timeout_s = parseInt(timeoutS, 10);
const confidenceMin = text('classifier-decision-confidence-min');
if (confidenceMin !== '') decision.confidence_min = parseFloat(confidenceMin);
const coverageMin = text('classifier-decision-coverage-min');
if (coverageMin !== '') decision.coverage_min = parseFloat(coverageMin);
decision.tier_enabled = document.getElementById('classifier-decision-tier-enabled').checked;
body.decision = decision;
}
return body;
}

View File

@@ -165,6 +165,12 @@ can't safely represent:
plus its companion block are written as one atomic change so an in-between
invalid state is never even written transiently.
The header badge names the mode that is **running**, not the one saved. A
save only persists to `config.local.yaml`, and the router reads this block at
startup, so an amber `restart pending` badge sits beside it until the service
restarts. A blank field is left out of the overlay, so the repo default keeps
applying; a typed `0` is saved as `0`.
When `local_decision` is selected the panel reveals a **Local Decision**
block with the fields from `LocalDecisionConfig` in `src/config.py`:
@@ -481,6 +487,12 @@ intermediate invalid state could land on disk. The GET response includes
`routing.cheapest_classifier_candidate` against the current catalog when
`cloud_primary_auto` is set, `null` otherwise.
`mode` in that response is the **saved** value. `running_mode` is what the
process is using, and `restart_pending` is `true` when the saved classifier
block differs from the running one in any field, or `null` when the saved
config no longer validates and the comparison cannot be made. The block is read
at import, so only a restart closes the gap.
**Model availability overrides**
`POST /admin/api/models/{model_id:path}/{provider}/availability` marks a model

View File

@@ -2684,9 +2684,29 @@ def build_router(
candidates = list(prof_categories)
excluded = [c for c in prof_categories if c not in candidates]
# What the RUNNING process is using, as opposed to what is saved above.
# cfg binds at import and POST only persists, so the two differ from
# the moment of a save until the next restart -- and reporting the
# saved value as if it were live is the echo-the-config bug this
# docstring opens by warning about. Compare the whole classifier block
# (mode AND its companion blocks): a saved model or confidence_min is
# just as inert until the bounce as a saved mode.
running_mode = cfg.classifier.mode
try:
saved_classifier = RouterConfig(**merged).classifier
restart_pending: Optional[bool] = (
saved_classifier.model_dump() != cfg.classifier.model_dump()
)
except ValidationError:
# The saved config does not validate, so "pending" is unknowable.
# Why it is invalid is the write path's job to say, not this field's.
restart_pending = None
return {
"mode": classifier.get("mode", "local_llm"),
"mode_source": mode_source,
"running_mode": running_mode,
"restart_pending": restart_pending,
"candidate_categories": list(candidates),
"excluded_categories": excluded,
"cloud_primary": classifier.get("cloud_primary"),

View File

@@ -183,6 +183,65 @@ def test_get_classifier_config_includes_decision_block(tmp_path):
assert body["decision"]["coverage_min"] == 0.42
# --- GET: saved vs running ---------------------------------------------
#
# POST only persists and cfg binds at import, so a saved classifier change is
# inert until a restart. GET must say so, or the card reads as live when it is
# not (which cost a restart cycle in practice). The router is built from the
# base config only (tests ignore the overlay), so anything written to the
# overlay is "saved but not running" by construction.
def test_get_reports_nothing_pending_when_saved_matches_running(tmp_path):
client, _config_yaml, _local_yaml = _client(tmp_path)
body = client.get("/admin/api/classifier-config").json()
assert body["running_mode"] == "local_llm"
assert body["mode"] == "local_llm"
assert body["restart_pending"] is False
def test_get_flags_a_saved_mode_change_as_pending_and_keeps_running_mode(tmp_path):
client, _config_yaml, _local_yaml = _client(tmp_path)
resp = client.post(
"/admin/api/classifier-config",
json={"mode": "local_decision", "decision": {}},
)
assert resp.status_code == 200
body = client.get("/admin/api/classifier-config").json()
assert body["mode"] == "local_decision" # what is saved
assert body["running_mode"] == "local_llm" # what is actually answering
assert body["restart_pending"] is True
def test_get_flags_a_companion_only_change_as_pending(tmp_path):
"""Same mode, different block: a saved cloud_fallback is just as inert
until the restart as a saved mode, so comparing modes alone is not enough."""
client, _config_yaml, _local_yaml = _client(
tmp_path,
overlay_yaml=(
"classifier:\n"
" cloud_fallback:\n"
" base_url: http://127.0.0.1:9/v1\n"
" model: some-model\n"
),
)
body = client.get("/admin/api/classifier-config").json()
assert body["mode"] == body["running_mode"] == "local_llm"
assert body["restart_pending"] is True
def test_get_reports_pending_as_unknown_when_the_saved_config_is_invalid(tmp_path):
"""An overlay that does not validate cannot be compared; null, not a guess."""
client, _config_yaml, _local_yaml = _client(
tmp_path,
overlay_yaml="classifier:\n mode: local_encoder\n encoder:\n device: tpu\n",
)
resp = client.get("/admin/api/classifier-config")
assert resp.status_code == 200
assert resp.json()["restart_pending"] is None
# --- POST: validated the same way config load is ------------------------

View File

@@ -743,3 +743,53 @@ def test_controls_html_local_decision_field_rendering():
assert "dec.confidence_min" in fields
assert "dec.coverage_min" in fields
assert "dec.tier_enabled" in fields
def test_classifier_card_inputs_all_carry_a_label():
"""Every input in the Classifier card's mode blocks has a <label for=...>.
The local_decision block once shipped six bare inputs with only a
placeholder, so once filled in nothing said which number was num_ctx and
which was timeout_s. The house pattern (the cloud_fallback card) is a
form-label naming the config key above each input.
"""
html = (ROOT / "admin" / "frontend" / "controls.html").read_text()
fields = _function_body(html, "classifierModeFieldsHtml")
input_ids = re.findall(r'<(?:input|select)\b[^>]*\bid="(classifier-[a-z-]+)"', fields)
assert input_ids, "no classifier inputs found; the pattern or the markup moved"
# The two toggles are labelled by their sibling form-check-label, same `for=`.
for input_id in input_ids:
assert f'for="{input_id}"' in fields, f"{input_id} has no <label for>"
def test_classifier_decision_collect_does_not_default_a_deliberate_zero():
"""`parseFloat(x) || 0.5` turns a typed 0 into 0.5, though the server accepts 0.
A blank field is left out of the body so the server default applies; a typed
value, including 0, is sent as typed.
"""
html = (ROOT / "admin" / "frontend" / "controls.html").read_text()
collect = _function_body(html, "collectClassifierConfigBody")
start = collect.index("mode === 'local_decision'")
decision_branch = collect[start:]
for default in ("|| 0.5", "|| 0.3", "|| 8192", "|| 10", "|| 'qwen3.5:4b'"):
assert default not in decision_branch, f"decision branch still defaults with {default!r}"
assert "confidenceMin !== ''" in decision_branch
assert "coverageMin !== ''" in decision_branch
def test_classifier_card_shows_running_mode_and_pending_restart():
"""The badge reads the RUNNING mode and an amber badge shows a pending restart.
GET /api/classifier-config returns the saved config; POST only persists and
the router reads the block at startup. Echoing the saved mode as the badge
made a saved-but-not-running classifier look live.
"""
html = (ROOT / "admin" / "frontend" / "controls.html").read_text()
assert 'id="classifier-pending-badge"' in html
# _function_body only matches plain `function`; the loader is async.
match = re.search(r"^async function loadClassifierConfig\(.*?^\}", html, re.S | re.M)
assert match, "loadClassifierConfig() not found in controls.html"
loader = match.group(0)
assert "data.running_mode" in loader
assert "data.restart_pending" in loader