diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 102af45..25c590e 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -275,41 +275,44 @@ function actorLabel(actor: string): string { } async function saveKbInject() { - const t = Math.min(1, Math.max(0, Number(kbInjectThreshold.value) || 0)); + // Every similarity bar, clamped the way the server's `bounded_float` clamps + // it: an unparseable value falls back to its default, never to 0, and `lo` + // is the floor a bar that BLOCKS must not go under (0.80 block, 0.70 overlap). + const asBar = (v: string, d: number, lo = 0) => Math.min(1, Math.max(lo, Number(v) || d)); + const t = asBar(kbInjectThreshold.value, 0); const k = Math.min(10, Math.max(1, Math.floor(Number(kbInjectTopK.value) || 1))); // `|| default` not `|| 0`: an unparseable value here should fall back to the // per-kind default, not to 0 — a 0 floor would report every record as a // duplicate of every other one. - const dupSnip = Math.min(1, Math.max(0, Number(kbDupThresholdSnippet.value) || 0.82)); - const dupNote = Math.min(1, Math.max(0, Number(kbDupThresholdNote.value) || 0.93)); - const dupTask = Math.min(1, Math.max(0, Number(kbDupThresholdTask.value) || 0.93)); + const dupSnip = asBar(kbDupThresholdSnippet.value, 0.82); + const dupNote = asBar(kbDupThresholdNote.value, 0.93); + const dupTask = asBar(kbDupThresholdTask.value, 0.93); // The gate BLOCKS a write, so its bars have a floor the server enforces too: // 0.80 for a block, 0.70 for the overlap list. - const gateAt = (v: string, d: number, lo: number) => Math.min(1, Math.max(lo, Number(v) || d)); - const gate = gateAt(kbGateThreshold.value, 0.9, 0.8); - const gateSnip = gateAt(kbGateThresholdSnippet.value, 0.96, 0.8); - const gateLesson = gateAt(kbGateThresholdLesson.value, 0.96, 0.8); - const gateCopy = gateAt(kbGateThresholdNoteCopy.value, 0.98, 0.8); - const gateOverlap = gateAt(kbGateNoteOverlapFloor.value, 0.87, 0.7); + const gate = asBar(kbGateThreshold.value, 0.9, 0.8); + const gateSnip = asBar(kbGateThresholdSnippet.value, 0.96, 0.8); + const gateLesson = asBar(kbGateThresholdLesson.value, 0.96, 0.8); + const gateCopy = asBar(kbGateThresholdNoteCopy.value, 0.98, 0.8); + const gateOverlap = asBar(kbGateNoteOverlapFloor.value, 0.87, 0.7); // Same `|| default` guard: a floor of 0 would hand back an existing plan // for every new one, and no plan could be started without force. - const planT = Math.min(1, Math.max(0, Number(kbPlanMatchThreshold.value) || 0.8)); + const planT = asBar(kbPlanMatchThreshold.value, 0.8); // Same `|| default` reasoning: falling back to 0 would surface every // snippet in the corpus on every edit, which is the failure this knob fixes. - const wpT = Math.min(1, Math.max(0, Number(kbWritePathThreshold.value) || 0.68)); + const wpT = asBar(kbWritePathThreshold.value, 0.68); // Same `|| default` reasoning again, and it bites harder here: a rule hint // fires on every write, so a fallback of 0 would attach a standing rule to // every edit in the session. - const rhT = Math.min(1, Math.max(0, Number(kbRuleHintThreshold.value) || 0.72)); + const rhT = asBar(kbRuleHintThreshold.value, 0.72); // Same `|| default` guard, and the same reason: this arm fires before every // Bash call, so a fallback of 0 would put a rule in front of every command. - const trT = Math.min(1, Math.max(0, Number(kbToolRuleThreshold.value) || 0.68)); - const prT = Math.min(1, Math.max(0, Number(kbPromptRuleThreshold.value) || 0.72)); + const trT = asBar(kbToolRuleThreshold.value, 0.68); + const prT = asBar(kbPromptRuleThreshold.value, 0.72); // The checkpoint bar, and the `|| default` guard matters most here of all: // this is the only number that can STOP a call, so a fallback of 0 would // hold the first command of every session behind whatever ranked first. - const cpT = Math.min(1, Math.max(0, Number(kbCheckpointThreshold.value) || 0.8)); - const rpT = Math.min(1, Math.max(0, Number(kbReportPrefThreshold.value) || 0.72)); + const cpT = asBar(kbCheckpointThreshold.value, 0.8); + const rpT = asBar(kbReportPrefThreshold.value, 0.72); // The budgets, clamped the way the server clamps them: a whole number in // [1, 10]. Never 0 — an arm turned off is turned off by its switch, and a // budget of zero would run the search, log the retrieval and render nothing, diff --git a/src/scribe/services/dedup.py b/src/scribe/services/dedup.py index d15e18b..208f086 100644 --- a/src/scribe/services/dedup.py +++ b/src/scribe/services/dedup.py @@ -287,16 +287,16 @@ def _gate_key(note_type: str) -> str: async def _gate_setting(user_id: int, key: str, lo: float) -> float: - from scribe.services.settings import get_setting + from scribe.services.settings import bounded_float, get_setting default = GATE_DEFAULT_THRESHOLDS[key] try: - value = float(await get_setting(user_id, GATE_THRESHOLD_KEYS[key], str(default))) + raw = await get_setting(user_id, GATE_THRESHOLD_KEYS[key], str(default)) except Exception: # Fail-open like the rest of the gate: an unreadable setting falls back # to the measured default rather than blocking or waving through. - value = default - return min(1.0, max(lo, value)) + raw = None + return bounded_float(raw, default, lo) async def gate_bars(user_id: int, note_type: str) -> tuple[float, float]: @@ -557,16 +557,11 @@ _MAX_DUPLICATE_PAIRS = 200 async def get_duplicate_threshold(user_id: int, kind: str = "snippet") -> float: """The user's near-duplicate similarity floor for `kind`, clamped to [0, 1].""" - from scribe.services.settings import get_setting + from scribe.services.settings import bounded_float, get_setting default = DUPLICATE_DEFAULT_THRESHOLDS[kind] - try: - value = float(await get_setting( - user_id, DUPLICATE_THRESHOLD_KEYS[kind], str(default) - )) - except (TypeError, ValueError): - value = default - return min(1.0, max(0.0, value)) + raw = await get_setting(user_id, DUPLICATE_THRESHOLD_KEYS[kind], str(default)) + return bounded_float(raw, default) def group_pairs(pairs: list[tuple[int, int, float]]) -> list[list[int]]: @@ -1120,15 +1115,12 @@ PLAN_MATCH_DEFAULT_THRESHOLD = 0.80 async def get_plan_match_threshold(user_id: int) -> float: """The user's plan-gate similarity floor, clamped to [0, 1].""" - from scribe.services.settings import get_setting + from scribe.services.settings import bounded_float, get_setting - try: - value = float(await get_setting( - user_id, PLAN_MATCH_THRESHOLD_KEY, str(PLAN_MATCH_DEFAULT_THRESHOLD) - )) - except (TypeError, ValueError): - value = PLAN_MATCH_DEFAULT_THRESHOLD - return min(1.0, max(0.0, value)) + raw = await get_setting( + user_id, PLAN_MATCH_THRESHOLD_KEY, str(PLAN_MATCH_DEFAULT_THRESHOLD) + ) + return bounded_float(raw, PLAN_MATCH_DEFAULT_THRESHOLD) def plan_candidate_text( diff --git a/src/scribe/services/retrieval_surfaces.py b/src/scribe/services/retrieval_surfaces.py index cecdf7e..fb104d5 100644 --- a/src/scribe/services/retrieval_surfaces.py +++ b/src/scribe/services/retrieval_surfaces.py @@ -66,7 +66,7 @@ from __future__ import annotations from dataclasses import dataclass -from scribe.services.settings import get_setting +from scribe.services.settings import bounded_float, get_setting # A budget nobody should be able to set past. Not a tuning value — a guard on # the worst case, so a mistyped setting cannot turn a menu into a wall of text. @@ -270,11 +270,8 @@ def dial_for_key(key: str) -> tuple[str, str] | None: async def floor_for(user_id: int, name: str) -> float: """This install's current floor for a surface, clamped to [0, 1].""" s = get_surface(name) - try: - value = float(await get_setting(user_id, s.floor_key, str(s.floor_default))) - except (TypeError, ValueError): - value = s.floor_default - return min(1.0, max(0.0, value)) + raw = await get_setting(user_id, s.floor_key, str(s.floor_default)) + return bounded_float(raw, s.floor_default) async def budget_for(user_id: int, name: str) -> int: diff --git a/src/scribe/services/settings.py b/src/scribe/services/settings.py index fb044aa..ae13359 100644 --- a/src/scribe/services/settings.py +++ b/src/scribe/services/settings.py @@ -16,6 +16,22 @@ logger = logging.getLogger(__name__) SECRET_MASK = "********" +def bounded_float(raw: str | None, default: float, lo: float = 0.0, hi: float = 1.0) -> float: + """A numeric setting as stored text, parsed and clamped to [lo, hi]. + + Every similarity bar and retrieval floor is kept as a string and read the + same way: an unparseable value falls back to the DEFAULT, never to 0 (a + floor of 0 admits everything), and a value out of range is pulled back + into it. Pure on purpose: each caller keeps its own `get_setting` read, so + what can fail there — and whether that fails open — stays the caller's. + """ + try: + value = float(raw) + except (TypeError, ValueError): + value = default + return min(hi, max(lo, value)) + + async def get_admin_setting(key: str, default: str = "") -> str: """Read an instance-global setting (one stored on an admin account).