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">
|
<div class="card-header">
|
||||||
<h3 class="card-title"><span class="me-2" data-icon="cpu"></span>Classifier</h3>
|
<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">
|
<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>
|
<span id="classifier-mode-badge" class="badge bg-secondary">—</span>
|
||||||
</div>
|
</div>
|
||||||
</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> →
|
cascade (stale session → session history → <code>cloud_fallback</code> →
|
||||||
the static guess) unmodified.
|
the static guess) unmodified.
|
||||||
</p>
|
</p>
|
||||||
<div class="row g-3 align-items-end">
|
<div class="row g-3 align-items-start">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label" for="classifier-mode-select" style="font-size:.78rem">mode</label>
|
<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"
|
<select class="form-select form-select-sm" id="classifier-mode-select"
|
||||||
@@ -1142,18 +1143,22 @@ function classifierModeFieldsHtml(mode, data) {
|
|||||||
</div>
|
</div>
|
||||||
<div id="classifier-cloud-pinned-fields" class="row g-2" style="${auto ? 'display:none' : ''}">
|
<div id="classifier-cloud-pinned-fields" class="row g-2" style="${auto ? 'display:none' : ''}">
|
||||||
<div class="col-md-6">
|
<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"
|
<input class="form-control form-control-sm" id="classifier-cloud-base-url"
|
||||||
placeholder="base_url" value="${escapeHtml(cp.base_url || '')}">
|
placeholder="base_url" value="${escapeHtml(cp.base_url || '')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<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"
|
<input class="form-control form-control-sm" id="classifier-cloud-model"
|
||||||
placeholder="model" value="${escapeHtml(cp.model || '')}">
|
placeholder="model" value="${escapeHtml(cp.model || '')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<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"
|
<input class="form-control form-control-sm" id="classifier-cloud-api-key-env"
|
||||||
placeholder="api_key_env (optional)" value="${escapeHtml(cp.api_key_env || '')}">
|
placeholder="api_key_env (optional)" value="${escapeHtml(cp.api_key_env || '')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<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"
|
<input class="form-control form-control-sm" type="number" id="classifier-cloud-timeout"
|
||||||
placeholder="timeout_seconds" value="${cp.timeout_seconds != null ? cp.timeout_seconds : ''}">
|
placeholder="timeout_seconds" value="${cp.timeout_seconds != null ? cp.timeout_seconds : ''}">
|
||||||
</div>
|
</div>
|
||||||
@@ -1164,20 +1169,23 @@ function classifierModeFieldsHtml(mode, data) {
|
|||||||
return `
|
return `
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-md-6">
|
<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"
|
<input class="form-control form-control-sm" id="classifier-encoder-model"
|
||||||
placeholder="model (default: BAAI/bge-large-en-v1.5)"
|
placeholder="model (default: BAAI/bge-large-en-v1.5)"
|
||||||
value="${escapeHtml(enc.model || '')}">
|
value="${escapeHtml(enc.model || '')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<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">
|
<select class="form-select form-select-sm" id="classifier-encoder-device">
|
||||||
<option value="cpu" ${enc.device !== 'cuda' ? 'selected' : ''}>cpu</option>
|
<option value="cpu" ${enc.device !== 'cuda' ? 'selected' : ''}>cpu</option>
|
||||||
<option value="cuda" ${enc.device === 'cuda' ? 'selected' : ''}>cuda</option>
|
<option value="cuda" ${enc.device === 'cuda' ? 'selected' : ''}>cuda</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<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">
|
<div class="input-group input-group-sm">
|
||||||
<input class="form-control form-control-sm" type="number" step="1" min="0" max="100"
|
<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) : ''}">
|
value="${enc.confidence_min != null ? Math.round(enc.confidence_min * 100) : ''}">
|
||||||
<span class="input-group-text">%</span>
|
<span class="input-group-text">%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1188,38 +1196,50 @@ function classifierModeFieldsHtml(mode, data) {
|
|||||||
const dec = (data && data.decision) || {};
|
const dec = (data && data.decision) || {};
|
||||||
return `
|
return `
|
||||||
<div class="row g-2">
|
<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"
|
<input class="form-control form-control-sm" id="classifier-decision-base-url"
|
||||||
data-key="decision.base_url"
|
data-key="decision.base_url"
|
||||||
placeholder="http://localhost:11434"
|
placeholder="http://localhost:11434"
|
||||||
value="${escapeHtml(dec.base_url || '')}">
|
value="${escapeHtml(dec.base_url || '')}">
|
||||||
</div>
|
</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"
|
<input class="form-control form-control-sm" id="classifier-decision-model"
|
||||||
data-key="decision.model"
|
data-key="decision.model"
|
||||||
placeholder="qwen3.5:4b"
|
placeholder="qwen3.5:4b"
|
||||||
value="${escapeHtml(dec.model || '')}">
|
value="${escapeHtml(dec.model || '')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<input class="form-control form-control-sm" type="number" id="classifier-decision-num-ctx"
|
<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"
|
data-key="decision.num_ctx"
|
||||||
placeholder="8192"
|
placeholder="8192"
|
||||||
value="${dec.num_ctx != null ? dec.num_ctx : ''}">
|
value="${dec.num_ctx != null ? dec.num_ctx : ''}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<input class="form-control form-control-sm" type="number" id="classifier-decision-timeout-s"
|
<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"
|
data-key="decision.timeout_s"
|
||||||
placeholder="10"
|
placeholder="10"
|
||||||
value="${dec.timeout_s != null ? dec.timeout_s : ''}">
|
value="${dec.timeout_s != null ? dec.timeout_s : ''}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<input class="form-control form-control-sm" type="number" step="0.1" id="classifier-decision-confidence-min"
|
<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"
|
data-key="decision.confidence_min"
|
||||||
placeholder="0.5"
|
placeholder="0.5"
|
||||||
value="${dec.confidence_min != null ? dec.confidence_min : ''}">
|
value="${dec.confidence_min != null ? dec.confidence_min : ''}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<input class="form-control form-control-sm" type="number" step="0.1" id="classifier-decision-coverage-min"
|
<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"
|
data-key="decision.coverage_min"
|
||||||
placeholder="0.3"
|
placeholder="0.3"
|
||||||
value="${dec.coverage_min != null ? dec.coverage_min : ''}">
|
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);
|
document.getElementById('classifier-mode-fields').innerHTML = classifierModeFieldsHtml(data.mode, data);
|
||||||
renderClassifierResolvedPrimary(data);
|
renderClassifierResolvedPrimary(data);
|
||||||
renderClassifierCandidateCategories(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');
|
const badge = document.getElementById('classifier-mode-badge');
|
||||||
badge.textContent = data.mode + (data.mode_source === 'overlay' ? ' (overlay)' : '');
|
badge.textContent = running + (data.mode_source === 'overlay' && !data.restart_pending ? ' (overlay)' : '');
|
||||||
badge.className = data.mode === 'local_llm' ? 'badge bg-secondary' : 'badge bg-info';
|
badge.className = running === 'local_llm' ? 'badge bg-secondary' : 'badge bg-info';
|
||||||
if (data.mode === 'local_decision' && data.decision) {
|
const pending = document.getElementById('classifier-pending-badge');
|
||||||
const d = data.decision;
|
if (data.restart_pending) {
|
||||||
if (d.base_url) document.getElementById('classifier-decision-base-url').value = d.base_url;
|
pending.textContent = data.mode !== running
|
||||||
if (d.model) document.getElementById('classifier-decision-model').value = d.model;
|
? `restart pending: ${data.mode}`
|
||||||
if (d.num_ctx) document.getElementById('classifier-decision-num-ctx').value = d.num_ctx;
|
: 'restart pending: settings changed';
|
||||||
if (d.timeout_s) document.getElementById('classifier-decision-timeout-s').value = d.timeout_s;
|
pending.title = `running ${running}, saved ${data.mode}; the router reads this block at startup`;
|
||||||
if (d.confidence_min) document.getElementById('classifier-decision-confidence-min').value = d.confidence_min;
|
pending.style.display = '';
|
||||||
if (d.coverage_min) document.getElementById('classifier-decision-coverage-min').value = d.coverage_min;
|
} else {
|
||||||
document.getElementById('classifier-decision-tier-enabled').checked = !!d.tier_enabled;
|
pending.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1377,15 +1401,25 @@ function collectClassifierConfigBody() {
|
|||||||
if (thresholdPct !== '') encoder.confidence_min = parseFloat(thresholdPct) / 100;
|
if (thresholdPct !== '') encoder.confidence_min = parseFloat(thresholdPct) / 100;
|
||||||
body.encoder = encoder;
|
body.encoder = encoder;
|
||||||
} else if (mode === 'local_decision') {
|
} else if (mode === 'local_decision') {
|
||||||
body.decision = {
|
// A blank field is left OUT so the server default applies and keeps floating
|
||||||
base_url: document.getElementById('classifier-decision-base-url')?.value || 'http://localhost:11434',
|
// with the repo. Never `value || default`: that rewrites a deliberate 0
|
||||||
model: document.getElementById('classifier-decision-model')?.value || 'qwen3.5:4b',
|
// (confidence_min 0 means "accept every verdict") into the default.
|
||||||
num_ctx: parseInt(document.getElementById('classifier-decision-num-ctx')?.value) || 8192,
|
const decision = {};
|
||||||
timeout_s: parseInt(document.getElementById('classifier-decision-timeout-s')?.value) || 10,
|
const text = (id) => document.getElementById(id).value.trim();
|
||||||
confidence_min: parseFloat(document.getElementById('classifier-decision-confidence-min')?.value) || 0.5,
|
const baseUrl = text('classifier-decision-base-url');
|
||||||
coverage_min: parseFloat(document.getElementById('classifier-decision-coverage-min')?.value) || 0.3,
|
if (baseUrl !== '') decision.base_url = baseUrl;
|
||||||
tier_enabled: document.getElementById('classifier-decision-tier-enabled')?.checked || false,
|
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;
|
return body;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,12 @@ can't safely represent:
|
|||||||
plus its companion block are written as one atomic change so an in-between
|
plus its companion block are written as one atomic change so an in-between
|
||||||
invalid state is never even written transiently.
|
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**
|
When `local_decision` is selected the panel reveals a **Local Decision**
|
||||||
block with the fields from `LocalDecisionConfig` in `src/config.py`:
|
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
|
`routing.cheapest_classifier_candidate` against the current catalog when
|
||||||
`cloud_primary_auto` is set, `null` otherwise.
|
`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**
|
**Model availability overrides**
|
||||||
|
|
||||||
`POST /admin/api/models/{model_id:path}/{provider}/availability` marks a model
|
`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)
|
candidates = list(prof_categories)
|
||||||
excluded = [c for c in prof_categories if c not in candidates]
|
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 {
|
return {
|
||||||
"mode": classifier.get("mode", "local_llm"),
|
"mode": classifier.get("mode", "local_llm"),
|
||||||
"mode_source": mode_source,
|
"mode_source": mode_source,
|
||||||
|
"running_mode": running_mode,
|
||||||
|
"restart_pending": restart_pending,
|
||||||
"candidate_categories": list(candidates),
|
"candidate_categories": list(candidates),
|
||||||
"excluded_categories": excluded,
|
"excluded_categories": excluded,
|
||||||
"cloud_primary": classifier.get("cloud_primary"),
|
"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
|
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 ------------------------
|
# --- 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.confidence_min" in fields
|
||||||
assert "dec.coverage_min" in fields
|
assert "dec.coverage_min" in fields
|
||||||
assert "dec.tier_enabled" 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