fix(admin): label the Classifier card and show what is running vs saved #108
@@ -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">—</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -260,7 +261,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
|
||||
cascade (stale session → session history → <code>cloud_fallback</code> →
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
20
src/admin.py
20
src/admin.py
@@ -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"),
|
||||
|
||||
@@ -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 ------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user