chore(inference): disable Call 1 end-to-end; Call 2 stays
overnight-whole-app: Call 1 (vision classification) is OUT.
- POST /api/v1/slabs/{id}/inference/call1 always returns 410 call1_disabled
(no model chat, no inference_log). Prompt seed/revision APIs kept dormant.
- Review: remove "Suggest with AI" / runInference (inferTaxon).
- GenerateText: remove Classify metadata (Call 1) button; Call 2 generate stays.
- PROMPTS.md banner documents Call 1 OUT / Call 2 STAYS.
- Settings: inference_enabled toggle annotated as Call-1 latch (dormant).
- Backend tests updated to assert 410; Call 2 tests unchanged.
This commit is contained in:
+17
-45
@@ -1,6 +1,8 @@
|
||||
"""Inference (S7): prompt revisions (REST), Call 1 vision classification, Call 2
|
||||
content prose, test-inference, inference-off server description, inference_log.
|
||||
Cites METADATA-35..115, SETTINGS-30/45/90.
|
||||
|
||||
overnight-whole-app: Call 1 POST is hard-disabled (410 call1_disabled). Call 2 stays.
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -295,51 +297,21 @@ def _enabled(conn, key: str) -> bool:
|
||||
|
||||
@router.post("/slabs/{slab_id}/inference/call1")
|
||||
async def call1(slab_id: str) -> dict:
|
||||
conn = get_connection()
|
||||
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone()
|
||||
if row is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
if not _enabled(conn, "inference_enabled"):
|
||||
return JSONResponse(status_code=409, content=_error("inference_disabled", "Inference is off"))
|
||||
base, key, model = _inference_creds(conn)
|
||||
if not base or not key:
|
||||
return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured"))
|
||||
client = InferenceClient(base, key, model)
|
||||
_seed_prompts(conn)
|
||||
conn.commit()
|
||||
act = _active_prompt(conn, "call1")
|
||||
prompt_text = act[1] if act else CALL1_SEED
|
||||
# Build vision message with processed photos (METADATA-35: long edge <= 1024) + taxonomy.
|
||||
photos = conn.execute(
|
||||
"SELECT id, seq, processed_blob FROM slab_photos "
|
||||
"WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
||||
(slab_id,),
|
||||
).fetchall()
|
||||
image_parts = []
|
||||
photo_meta: list[dict] = []
|
||||
for p in photos:
|
||||
blob = p["processed_blob"] if hasattr(p, "keys") else p[2]
|
||||
pid = p["id"] if hasattr(p, "keys") else p[0]
|
||||
seq = p["seq"] if hasattr(p, "keys") else p[1]
|
||||
image_parts.append(_image_part_from_processed(blob))
|
||||
photo_meta.append(_photo_meta_row(pid, seq, blob))
|
||||
tax = _taxonomy_lists(conn)
|
||||
text = (f"{prompt_text}\n\nLISTS:\n{json.dumps(tax, ensure_ascii=False)}\n\n"
|
||||
f"SKU={row['sku']} length_in={row['length_in']} thickness_in={row['thickness_in']}")
|
||||
messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}]
|
||||
t0 = time.time()
|
||||
try:
|
||||
raw = client.chat(messages)
|
||||
except InferenceConnectionError as e:
|
||||
return JSONResponse(status_code=503, content=_error("inference_unreachable", str(e), {"recoverable": True}))
|
||||
except InferenceError as e:
|
||||
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
|
||||
latency = int((time.time() - t0) * 1000)
|
||||
parsed = _parse_json(raw)
|
||||
_log_call(conn, slab_id, 1, act[0] if act else None, model, messages, raw, latency, photo_meta)
|
||||
# Apply confidence gate (METADATA-45).
|
||||
suggestions = _gate(parsed)
|
||||
return {"ok": True, "suggestions": suggestions}
|
||||
"""Call 1 OUT (overnight-whole-app): hard-disabled. Call 2 stays.
|
||||
|
||||
Route retained so clients get an explicit 410 instead of a half-wired path.
|
||||
Prompt seed / revision APIs for call1 remain for docs & future re-enable.
|
||||
"""
|
||||
_ = slab_id # unused while disabled
|
||||
return JSONResponse(
|
||||
status_code=410,
|
||||
content=_error(
|
||||
"call1_disabled",
|
||||
"Call 1 (vision classification) is disabled on this branch. Use Call 2 for description text.",
|
||||
{"call": 1, "disabled": True},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _feat_display_name(name: str) -> str:
|
||||
|
||||
@@ -153,42 +153,28 @@ def test_gate_feature_tag_ids_not_free_text():
|
||||
assert "feature_tag_ids" not in _gate(low)
|
||||
|
||||
|
||||
def test_call1_confidence_gate(env, monkeypatch):
|
||||
def test_call1_hard_disabled_410(env, monkeypatch):
|
||||
"""overnight-whole-app: Call 1 OUT — POST always 410, never chats the model."""
|
||||
client, conn = env
|
||||
# enable inference
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)")
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')")
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
|
||||
called = {"n": 0}
|
||||
|
||||
def fake_chat(self, messages):
|
||||
# Capture list header for #86 shape
|
||||
text = messages[0]["content"][-1]["text"]
|
||||
assert "LISTS:" in text
|
||||
assert "grade_terms" not in text
|
||||
assert "species_leaves" not in text
|
||||
return json.dumps({
|
||||
"species_id": 1, "species_confidence": 0.9,
|
||||
"wood_category_ids": [1], "wood_category_confidence": 0.5, # below threshold
|
||||
"grade_term_id": 7, "grade_confidence": 0.99, # never suggested
|
||||
"figure_term_ids": [2], "figure_confidence": 0.8, # at/above
|
||||
"feature_tag_ids": [9], "feature_tag_confidence": 0.9,
|
||||
"character": ["x"], "inclusions": ["y"],
|
||||
})
|
||||
called["n"] += 1
|
||||
return "{}"
|
||||
|
||||
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 200, r.text
|
||||
s = r.json()["suggestions"]
|
||||
assert s.get("species_id") == 1
|
||||
assert s.get("figure_term_ids") == [2]
|
||||
assert s.get("feature_tag_ids") == [9]
|
||||
assert "wood_category_ids" not in s # gated out
|
||||
assert "grade_term_id" not in s # never
|
||||
assert "character" not in s
|
||||
# inference_log written with call=1
|
||||
assert conn.execute("SELECT call FROM inference_log").fetchone()["call"] == 1
|
||||
assert r.status_code == 410, r.text
|
||||
body = r.json()
|
||||
assert body["error"]["code"] == "call1_disabled"
|
||||
assert called["n"] == 0
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM inference_log").fetchone()["n"] == 0
|
||||
|
||||
|
||||
def test_call2_stores_description(env, monkeypatch):
|
||||
@@ -208,12 +194,14 @@ def test_call2_stores_description(env, monkeypatch):
|
||||
assert row["content_source"] == "llm"
|
||||
|
||||
|
||||
def test_inference_off_call1_409(env):
|
||||
def test_call1_disabled_even_when_inference_off(env):
|
||||
"""Call 1 hard-disable wins over inference_enabled=false (still 410, not 409)."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT INTO slabs(id,sku,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01','calibrated',1,1,'t','t')")
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 409 # inference_enabled default off
|
||||
assert r.status_code == 410
|
||||
assert r.json()["error"]["code"] == "call1_disabled"
|
||||
|
||||
|
||||
def test_server_description_inference_off(env):
|
||||
@@ -306,50 +294,18 @@ def test_redact_request_for_log_strips_data_urls_keeps_text():
|
||||
assert "LISTS:" in parts[2]["text"]
|
||||
|
||||
|
||||
def test_call1_log_has_text_and_image_refs_no_base64(env, monkeypatch):
|
||||
"""#87 METADATA-66: inference_log stores text+refs, not image bytes."""
|
||||
def test_call1_log_skipped_while_disabled(env):
|
||||
"""#87 Call 1 log path dormant while Call 1 is hard-disabled (overnight-whole-app)."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')"
|
||||
)
|
||||
png = _rgba_png(400, 300)
|
||||
conn.execute(
|
||||
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('photo-abc','s1',1,?, 't')",
|
||||
(png,),
|
||||
)
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
|
||||
def fake_chat(self, messages):
|
||||
# Wire path still has data-URL for the model
|
||||
content = messages[0]["content"]
|
||||
assert any(
|
||||
isinstance(p, dict) and p.get("type") == "image_url"
|
||||
and str(p.get("image_url", {}).get("url", "")).startswith("data:image")
|
||||
for p in content
|
||||
)
|
||||
return json.dumps({"species_id": 1, "species_confidence": 0.9})
|
||||
|
||||
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 200, r.text
|
||||
row = conn.execute(
|
||||
"SELECT request_payload, length(request_payload) AS n FROM inference_log ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
payload = row["request_payload"]
|
||||
assert row["n"] < 50000
|
||||
assert "data:image" not in payload
|
||||
assert "LISTS:" in payload
|
||||
assert "image_ref" in payload
|
||||
assert "photo-abc" in payload
|
||||
assert '"seq": 1' in payload or '"seq":1' in payload
|
||||
logged = json.loads(payload)
|
||||
refs = [p for p in logged[0]["content"] if p.get("type") == "image_ref"]
|
||||
assert len(refs) == 1
|
||||
assert refs[0]["photo_id"] == "photo-abc"
|
||||
assert refs[0]["bytes"] == len(png)
|
||||
assert r.status_code == 410
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM inference_log").fetchone()["n"] == 0
|
||||
|
||||
|
||||
def test_call2_log_redacts_images(env, monkeypatch):
|
||||
@@ -380,38 +336,9 @@ def test_call2_log_redacts_images(env, monkeypatch):
|
||||
assert "DIMENSION FACT BLOCK" in payload
|
||||
|
||||
|
||||
def test_call1_sends_long_edge_at_most_1024(env, monkeypatch):
|
||||
"""METADATA-35 end-to-end: messages to the model use <=1024 long edge."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')"
|
||||
)
|
||||
big = _rgba_png(800, 1067)
|
||||
conn.execute(
|
||||
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,?, 't')",
|
||||
(big,),
|
||||
)
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_chat(self, messages):
|
||||
captured["messages"] = messages
|
||||
return json.dumps({"species_id": 1, "species_confidence": 0.9})
|
||||
|
||||
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 200, r.text
|
||||
content = captured["messages"][0]["content"]
|
||||
imgs = [p for p in content if isinstance(p, dict) and p.get("type") == "image_url"]
|
||||
assert len(imgs) == 1
|
||||
url = imgs[0]["image_url"]["url"]
|
||||
assert url.startswith("data:image/png;base64,")
|
||||
raw = base64.b64decode(url.split(",", 1)[1])
|
||||
im = Image.open(io.BytesIO(raw))
|
||||
assert max(im.size) <= 1024
|
||||
def test_call1_sends_long_edge_dormant_while_disabled(env):
|
||||
"""METADATA-35 Call 1 sizing path dormant while Call 1 is hard-disabled."""
|
||||
client, _conn = env
|
||||
r = client.post("/api/v1/slabs/missing/inference/call1")
|
||||
assert r.status_code == 410
|
||||
assert r.json()["error"]["code"] == "call1_disabled"
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Prompts contract
|
||||
|
||||
> **overnight-whole-app / Call 1 OUT:** Call 1 (vision classification) is hard-disabled.
|
||||
> `POST /api/v1/slabs/{id}/inference/call1` returns **410 call1_disabled**. UI triggers
|
||||
> (Review "Suggest with AI", GenerateText classify) are removed. Call 2
|
||||
> (`POST .../inference/call2`, Review "Generate description") stays invokable.
|
||||
> Call 1 seed text and prompt-revision APIs remain for documentation / future re-enable.
|
||||
|
||||
Status: SPEC · 2026-09-18.
|
||||
Owns prompt storage, prompt inputs, and the two inference calls.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// photos.py POST/GET /api/v1/slabs/{id}/photos[.../original|processed]
|
||||
// review.py POST /api/v1/slabs/{id}/review
|
||||
// publish.py POST /api/v1/slabs/{id}/publish
|
||||
// inference.py POST /api/v1/slabs/{id}/inference/call1|call2
|
||||
// inference.py POST /api/v1/slabs/{id}/inference/call1 (DISABLED 410) | call2
|
||||
// settings.py GET/PUT /api/v1/settings ; POST /api/v1/settings/test-woo|test-inference
|
||||
// taxonomy.py GET/POST /api/v1/admin/taxonomy[.../sync]
|
||||
// pricing.py CRUD /api/v1/admin/pricing[/{id}]
|
||||
@@ -332,6 +332,7 @@ export interface Call2Result {
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Call 1 OUT (overnight-whole-app): always fails with 410 call1_disabled. Prefer manual taxonomy. */
|
||||
export function inferTaxon(slabId: string): Promise<Call1Result> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call1`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
let contentEnabled = false;
|
||||
let reachable = false;
|
||||
let description: string | null = null;
|
||||
let call1Suggestions: Record<string, unknown> | null = null;
|
||||
let error = '';
|
||||
let busy = false;
|
||||
|
||||
@@ -41,25 +40,6 @@
|
||||
busy = false;
|
||||
}
|
||||
|
||||
async function classify() {
|
||||
if (!slabId) {
|
||||
error = 'Enter a slab id.';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
error = '';
|
||||
call1Suggestions = null;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call1`, { method: 'POST' });
|
||||
const body = await res.json();
|
||||
if (res.ok) call1Suggestions = body.suggestions;
|
||||
else error = body.error?.message || `call1 failed (${res.status})`;
|
||||
} catch {
|
||||
error = 'network error';
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
|
||||
checkReadiness();
|
||||
</script>
|
||||
|
||||
@@ -68,6 +48,7 @@
|
||||
<p class="state">
|
||||
Content generation: {contentEnabled ? 'on' : 'off'} · endpoint: {reachable ? 'reachable' : 'unreachable'}
|
||||
</p>
|
||||
<p class="note">Call 1 (vision classification) is disabled on this branch. Call 2 description generation stays.</p>
|
||||
{#if !contentEnabled || !reachable}
|
||||
<p class="note">The Generate text control is disabled: it appears only when content generation is
|
||||
enabled and the inference endpoint is reachable.</p>
|
||||
@@ -78,13 +59,9 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<button on:click={generate} disabled={busy}>{busy ? 'Working…' : 'Generate description (Call 2)'}</button>
|
||||
<button on:click={classify} disabled={busy}>Classify metadata (Call 1)</button>
|
||||
</div>
|
||||
{#if error}<p class="err">{error}</p>{/if}
|
||||
{#if description}<p class="desc">{description}</p>{/if}
|
||||
{#if call1Suggestions}
|
||||
<pre class="sug">{JSON.stringify(call1Suggestions, null, 2)}</pre>
|
||||
{/if}
|
||||
{/if}
|
||||
<button class="mini" on:click={checkReadiness}>Recheck readiness</button>
|
||||
</div>
|
||||
@@ -98,6 +75,5 @@
|
||||
.note { color: #e8a; }
|
||||
.err { color: #e88; }
|
||||
.desc { margin-top: 12px; }
|
||||
.sug { background: #1c1c1c; padding: 8px; }
|
||||
.mini { margin-top: 12px; font-size: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import {
|
||||
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
|
||||
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferContent,
|
||||
listPhotos, persistLineup, photoUrl, ApiError,
|
||||
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow,
|
||||
} from '../../api/slabs';
|
||||
@@ -143,8 +143,6 @@
|
||||
let commitToken = 0;
|
||||
|
||||
// --- Inference ---
|
||||
let aiBusy = false;
|
||||
let aiNote = '';
|
||||
let genBusy = false;
|
||||
let genError = '';
|
||||
let canGenerateContent = false;
|
||||
@@ -346,7 +344,7 @@
|
||||
aiRecommendations.push(...(s.figure_term_ids ?? []));
|
||||
}
|
||||
|
||||
// AI-marked option views (badge on rows after Call 1 / stored confidences).
|
||||
// AI-marked option views (legacy Call 1 badges; Call 1 disabled on this branch).
|
||||
$: speciesOptsView = speciesOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||||
$: woodCatGroupsView = woodCatGroups.map((g) => ({
|
||||
parent: g.parent,
|
||||
@@ -494,40 +492,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function runInference() {
|
||||
if (!slab) return;
|
||||
aiBusy = true;
|
||||
aiNote = '';
|
||||
try {
|
||||
const r = await inferTaxon(slabId);
|
||||
const s = r.suggestions;
|
||||
const recs: number[] = [];
|
||||
if (s.species_id) { speciesSel = String(s.species_id); recs.push(s.species_id); }
|
||||
if (s.wood_category_ids) {
|
||||
woodCatIds = [...s.wood_category_ids];
|
||||
primaryCatId = nextPrimary(woodCatIds, primaryCatId);
|
||||
recs.push(...s.wood_category_ids);
|
||||
}
|
||||
if (s.edge_type_term_id) edgeSel = String(s.edge_type_term_id);
|
||||
if (s.figure_term_ids) { figureIds = [...s.figure_term_ids]; recs.push(...s.figure_term_ids); }
|
||||
if (s.feature_tag_ids?.length) {
|
||||
featTagIds = [...s.feature_tag_ids];
|
||||
recs.push(...s.feature_tag_ids);
|
||||
}
|
||||
// Grade is not AI-suggested (METADATA-40) — owner judgment. Do not auto-fill.
|
||||
aiRecommendations = recs;
|
||||
aiNote = recs.length ? `AI suggested ${recs.length} field${recs.length === 1 ? '' : 's'} — review & edit below.` : 'No confident AI suggestions.';
|
||||
await commitReview();
|
||||
// #90: force the <select> DOM to reflect the prefilled values after Svelte's
|
||||
// own select_option(reset) has run.
|
||||
syncSelects();
|
||||
} catch (e) {
|
||||
aiNote = e instanceof ApiError ? e.message : 'Inference unavailable.';
|
||||
} finally {
|
||||
aiBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateContent() {
|
||||
if (!slab || !canCreateDesc) return;
|
||||
genBusy = true;
|
||||
@@ -687,14 +651,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if settings?.inference_enabled}
|
||||
<div class="aibar">
|
||||
<button type="button" class="ai-btn" data-testid="run-inference" on:click={runInference} disabled={aiBusy}>
|
||||
{aiBusy ? 'Reading slab…' : 'Suggest with AI'}
|
||||
</button>
|
||||
{#if aiNote}<p class="ai-note" data-testid="ai-note">{aiNote}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Call 1 OUT (overnight-whole-app): Suggest-with-AI removed. Call 2 generateContent stays. -->
|
||||
|
||||
<!-- Species (single, AI-assist) -->
|
||||
<div class="field">
|
||||
@@ -793,7 +750,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- feat-* product tags (optional, AI-assisted by Call 1) -->
|
||||
<!-- feat-* product tags (optional; Call 1 assist disabled on this branch) -->
|
||||
<div class="field">
|
||||
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
|
||||
<MultiSelect
|
||||
|
||||
@@ -326,7 +326,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label><input type="checkbox" bind:checked={form.inference_enabled} /> Inference Enabled (default false)</label>
|
||||
<label><input type="checkbox" bind:checked={form.inference_enabled} /> Inference Enabled (Call 1 — DISABLED on this branch; toggle kept for settings round-trip)</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user