feat(dedup): the create gate's similarity bars are settings (#4385, rule 25)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / Build & push image (push) Successful in 38s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / Build & push image (push) Successful in 38s
gate_bars(user_id, note_type) resolves the block bar and, for notes and tasks, the overlap floor from kb_gate_* settings, with the old constants as defaults. Fail-open on an unreadable value; a block bar clamps at 0.80 and the overlap floor at 0.70 and never above the bar. Five fields in Settings beside the duplicate-report floors. CLAIM_LEASE stays a constant, with the reason written at it: a per-user lease would make one shared task live to one reader and dead to another. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -120,6 +120,12 @@ const loadingTuning = ref(false);
|
||||
const kbDupThresholdSnippet = ref("0.82");
|
||||
const kbDupThresholdNote = ref("0.93");
|
||||
const kbDupThresholdTask = ref("0.93");
|
||||
// The create gate's bars (#4385). Defaults mirror services/dedup.py.
|
||||
const kbGateThreshold = ref("0.9");
|
||||
const kbGateThresholdSnippet = ref("0.96");
|
||||
const kbGateThresholdLesson = ref("0.96");
|
||||
const kbGateThresholdNoteCopy = ref("0.98");
|
||||
const kbGateNoteOverlapFloor = ref("0.87");
|
||||
// PLAN_MATCH_DEFAULT_THRESHOLD in services/dedup.py.
|
||||
const kbPlanMatchThreshold = ref("0.80");
|
||||
const savingKbInject = ref(false);
|
||||
@@ -277,6 +283,14 @@ async function saveKbInject() {
|
||||
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));
|
||||
// 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);
|
||||
// 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));
|
||||
@@ -317,6 +331,11 @@ async function saveKbInject() {
|
||||
kbDupThresholdSnippet.value = String(dupSnip);
|
||||
kbDupThresholdNote.value = String(dupNote);
|
||||
kbDupThresholdTask.value = String(dupTask);
|
||||
kbGateThreshold.value = String(gate);
|
||||
kbGateThresholdSnippet.value = String(gateSnip);
|
||||
kbGateThresholdLesson.value = String(gateLesson);
|
||||
kbGateThresholdNoteCopy.value = String(gateCopy);
|
||||
kbGateNoteOverlapFloor.value = String(gateOverlap);
|
||||
kbPlanMatchThreshold.value = String(planT);
|
||||
kbWritePathThreshold.value = String(wpT);
|
||||
kbRuleHintThreshold.value = String(rhT);
|
||||
@@ -369,6 +388,11 @@ async function saveKbInject() {
|
||||
kb_duplicate_threshold_snippet: String(dupSnip),
|
||||
kb_duplicate_threshold_note: String(dupNote),
|
||||
kb_duplicate_threshold_task: String(dupTask),
|
||||
kb_gate_threshold: String(gate),
|
||||
kb_gate_threshold_snippet: String(gateSnip),
|
||||
kb_gate_threshold_lesson: String(gateLesson),
|
||||
kb_gate_threshold_note_copy: String(gateCopy),
|
||||
kb_gate_note_overlap_floor: String(gateOverlap),
|
||||
kb_plan_match_threshold: String(planT),
|
||||
});
|
||||
kbInjectSaved.value = true;
|
||||
@@ -866,6 +890,15 @@ onMounted(async () => {
|
||||
if (allSettings.kb_duplicate_threshold_task !== undefined) {
|
||||
kbDupThresholdTask.value = allSettings.kb_duplicate_threshold_task;
|
||||
}
|
||||
for (const [key, target] of [
|
||||
["kb_gate_threshold", kbGateThreshold],
|
||||
["kb_gate_threshold_snippet", kbGateThresholdSnippet],
|
||||
["kb_gate_threshold_lesson", kbGateThresholdLesson],
|
||||
["kb_gate_threshold_note_copy", kbGateThresholdNoteCopy],
|
||||
["kb_gate_note_overlap_floor", kbGateNoteOverlapFloor],
|
||||
] as const) {
|
||||
if (allSettings[key] !== undefined) target.value = allSettings[key];
|
||||
}
|
||||
if (allSettings.kb_plan_match_threshold !== undefined) {
|
||||
kbPlanMatchThreshold.value = allSettings.kb_plan_match_threshold;
|
||||
}
|
||||
@@ -2019,6 +2052,91 @@ async function deleteUser(userId: number) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="kb-gate-threshold">Create gate — general block threshold</label>
|
||||
<input
|
||||
id="kb-gate-threshold"
|
||||
v-model="kbGateThreshold"
|
||||
type="number"
|
||||
min="0.8"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="fs-input input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
When a new record is at least this alike to an existing one of the same kind, the create is refused and pointed at the existing record to update. Applies to kinds without their own bar below (processes, and anything new). Higher = fewer refusals, more duplicates let through. Cannot go below 0.80: the gate blocks a write, and a low bar refuses everything on a shared topic.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="kb-gate-threshold-snippet">Create gate — snippets</label>
|
||||
<input
|
||||
id="kb-gate-threshold-snippet"
|
||||
v-model="kbGateThresholdSnippet"
|
||||
type="number"
|
||||
min="0.8"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="fs-input input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
The semantic backstop for snippets. Code and location are checked first and exactly, so this only catches a reworded copy; it sits above the band where deliberate siblings (a button and its outline variant) land.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="kb-gate-threshold-lesson">Create gate — lessons</label>
|
||||
<input
|
||||
id="kb-gate-threshold-lesson"
|
||||
v-model="kbGateThresholdLesson"
|
||||
type="number"
|
||||
min="0.8"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="fs-input input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
Lessons about one area read alike without being the same lesson, so this is high. A refusal loses a real lesson outright; a miss leaves two you can merge.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="kb-gate-threshold-note-copy">Create gate — notes and tasks (copy)</label>
|
||||
<input
|
||||
id="kb-gate-threshold-note-copy"
|
||||
v-model="kbGateThresholdNoteCopy"
|
||||
type="number"
|
||||
min="0.8"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="fs-input input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
Notes and tasks are refused only as a near-exact copy. Consecutive dev-logs and parts of one design score 0.90–0.98 without being duplicates, so a lower bar refuses the next one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="kb-gate-note-overlap-floor">Create gate — notes and tasks (listed overlaps)</label>
|
||||
<input
|
||||
id="kb-gate-note-overlap-floor"
|
||||
v-model="kbGateNoteOverlapFloor"
|
||||
type="number"
|
||||
min="0.7"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="fs-input input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
Below the copy bar, matches above this are listed on the create reply (up to three) for the session to judge, and the record is still created. Lower = more listed, more of them noise. Never above the copy bar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="kb-plan-match-threshold">Existing-plan match threshold</label>
|
||||
<input
|
||||
|
||||
@@ -113,6 +113,31 @@ _NOTE_OVERLAP_LIMIT = 3
|
||||
# gate was not part of the measurement, so it keeps the general bar.
|
||||
_COPY_BAND_TYPES = {"note"}
|
||||
|
||||
# The constants above are the DEFAULTS; each is a setting (rule 25, #4385),
|
||||
# because how alike two distinct records get depends on how uniform a corpus
|
||||
# is, which nobody can know from here.
|
||||
GATE_THRESHOLD_KEYS = {
|
||||
"general": "kb_gate_threshold",
|
||||
SNIPPET_NOTE_TYPE: "kb_gate_threshold_snippet",
|
||||
LESSON_NOTE_TYPE: "kb_gate_threshold_lesson",
|
||||
"note_copy": "kb_gate_threshold_note_copy",
|
||||
"note_overlap": "kb_gate_note_overlap_floor",
|
||||
}
|
||||
GATE_DEFAULT_THRESHOLDS = {
|
||||
"general": _SEMANTIC_THRESHOLD,
|
||||
SNIPPET_NOTE_TYPE: _SNIPPET_SEMANTIC_THRESHOLD,
|
||||
LESSON_NOTE_TYPE: _LESSON_SEMANTIC_THRESHOLD,
|
||||
"note_copy": _NOTE_COPY_THRESHOLD,
|
||||
"note_overlap": _NOTE_OVERLAP_FLOOR,
|
||||
}
|
||||
# A BLOCK bar may not be set below this. The gate refuses the write, so a
|
||||
# mistyped 0.1 would refuse every create that shared a topic with anything —
|
||||
# the report's floors can go low because a report only proposes.
|
||||
_GATE_MIN_BLOCK = 0.80
|
||||
# The overlap floor only decides what is LISTED for the session to judge, so it
|
||||
# may go lower — but not so low that the three slots fill with noise.
|
||||
_GATE_MIN_OVERLAP = 0.70
|
||||
|
||||
# The gate queries per CHUNK of the candidate (#280) — this caps how many
|
||||
# searches one save may cost. Eight chunks ≈ five thousand words of candidate;
|
||||
# a duplicate hiding past that is the duplicate report's job to find, not a
|
||||
@@ -248,16 +273,43 @@ async def _find_snippet_by_structure(
|
||||
|
||||
|
||||
def _semantic_threshold(note_type: str) -> float:
|
||||
"""The semantic bar for this kind — a lookup, so the kinds that need a
|
||||
different one are named in a single place rather than in a conditional
|
||||
that grows a branch per kind."""
|
||||
if note_type == SNIPPET_NOTE_TYPE:
|
||||
return _SNIPPET_SEMANTIC_THRESHOLD
|
||||
if note_type == LESSON_NOTE_TYPE:
|
||||
return _LESSON_SEMANTIC_THRESHOLD
|
||||
"""The DEFAULT semantic bar for this kind — what `gate_bars` falls back on
|
||||
when the user has not set one."""
|
||||
return GATE_DEFAULT_THRESHOLDS[_gate_key(note_type)]
|
||||
|
||||
|
||||
def _gate_key(note_type: str) -> str:
|
||||
if note_type in (SNIPPET_NOTE_TYPE, LESSON_NOTE_TYPE):
|
||||
return note_type
|
||||
if note_type in _COPY_BAND_TYPES:
|
||||
return _NOTE_COPY_THRESHOLD
|
||||
return _SEMANTIC_THRESHOLD
|
||||
return "note_copy"
|
||||
return "general"
|
||||
|
||||
|
||||
async def _gate_setting(user_id: int, key: str, lo: float) -> float:
|
||||
from scribe.services.settings import get_setting
|
||||
|
||||
default = GATE_DEFAULT_THRESHOLDS[key]
|
||||
try:
|
||||
value = float(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))
|
||||
|
||||
|
||||
async def gate_bars(user_id: int, note_type: str) -> tuple[float, float]:
|
||||
"""(block_at, overlap_floor) for `note_type` on this user's install.
|
||||
|
||||
The overlap floor is only read for the copy-band kinds, and never sits
|
||||
above the block bar — a floor over the bar would list nothing.
|
||||
"""
|
||||
block_at = await _gate_setting(user_id, _gate_key(note_type), _GATE_MIN_BLOCK)
|
||||
if note_type not in _COPY_BAND_TYPES:
|
||||
return block_at, block_at
|
||||
floor = await _gate_setting(user_id, "note_overlap", _GATE_MIN_OVERLAP)
|
||||
return block_at, min(floor, block_at)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -355,7 +407,7 @@ async def find_duplicate_note(
|
||||
# under its name and embedded under `name — trigger`, so the query
|
||||
# document is built the way the corpus was, from `data`.
|
||||
doc_title = embeddings_svc.document_title(title, note_type, data, body)
|
||||
block_at = _semantic_threshold(note_type)
|
||||
block_at, overlap_floor = await gate_bars(user_id, note_type)
|
||||
collect = overlaps is not None and note_type in _COPY_BAND_TYPES
|
||||
near: dict[int, NoteOverlap] = {}
|
||||
for query in embeddings_svc.chunk_document(doc_title, body)[:_GATE_MAX_CHUNKS]:
|
||||
@@ -369,7 +421,7 @@ async def find_duplicate_note(
|
||||
user_id, query, project_id=project_id, is_task=is_task,
|
||||
orphan_only=(project_id is None),
|
||||
limit=3,
|
||||
threshold=_NOTE_OVERLAP_FLOOR if collect else block_at,
|
||||
threshold=overlap_floor if collect else block_at,
|
||||
# Owner-only, deliberately: this gate BLOCKS a create and tells
|
||||
# the caller to update the match instead. Matching someone
|
||||
# else's record would refuse their write and point them at
|
||||
|
||||
@@ -37,6 +37,12 @@ if TYPE_CHECKING:
|
||||
# compaction, a resume or a long read does not kill it; short enough that a
|
||||
# session gone overnight reads as gone. The cost either way is stated rather
|
||||
# than hidden: readers show the age beside `live`, never the boolean alone.
|
||||
#
|
||||
# NOT A SETTING, deliberately (#4385 weighed it). Settings are per user, and a
|
||||
# claim is read by everyone who can see the task: a per-user lease would make
|
||||
# one shared task read as live to one collaborator and dead to another, and
|
||||
# "is anyone on this?" only means something if every reader gets the same
|
||||
# answer. It is also read synchronously in `to_dict`, which has no user to ask.
|
||||
CLAIM_LEASE = timedelta(hours=2)
|
||||
|
||||
|
||||
|
||||
@@ -178,3 +178,24 @@ def _no_rule_overlap():
|
||||
with patch("scribe.services.dedup.find_overlapping_rules",
|
||||
AsyncMock(return_value=[])):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _default_gate_bars():
|
||||
"""Read the create gate's bars as their defaults, not from settings (#4385).
|
||||
|
||||
Every create through the note gate now asks the user's settings for its
|
||||
similarity bars, which is a database read on a path the gate's unit tests
|
||||
run without one. Stubbed one level down — `_gate_setting`, not `gate_bars`
|
||||
— so the copy-band logic above it (which kinds read an overlap floor, the
|
||||
floor never sitting above the bar) still runs in every test. The setting
|
||||
read itself is tested in tests/test_gate_settings.py, which binds the real
|
||||
function at import time, before this patch runs.
|
||||
"""
|
||||
from scribe.services.dedup import GATE_DEFAULT_THRESHOLDS
|
||||
|
||||
async def _default(user_id, key, lo):
|
||||
return GATE_DEFAULT_THRESHOLDS[key]
|
||||
|
||||
with patch("scribe.services.dedup._gate_setting", AsyncMock(side_effect=_default)):
|
||||
yield
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""The create gate's similarity bars are settings, clamped and fail-open (#4385)."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.services.dedup import (
|
||||
_GATE_MIN_BLOCK,
|
||||
_GATE_MIN_OVERLAP,
|
||||
GATE_DEFAULT_THRESHOLDS,
|
||||
_gate_setting,
|
||||
)
|
||||
|
||||
# Bound at import, before the autouse stub in conftest replaces the module
|
||||
# attribute — so these tests exercise the real read.
|
||||
_real_gate_setting = _gate_setting
|
||||
|
||||
|
||||
def _setting(value):
|
||||
return patch("scribe.services.settings.get_setting", AsyncMock(return_value=value))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_set_value_is_used():
|
||||
with _setting("0.95"):
|
||||
assert await _real_gate_setting(7, "note_copy", _GATE_MIN_BLOCK) == 0.95
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_block_bar_cannot_be_set_low_enough_to_refuse_everything():
|
||||
with _setting("0.1"):
|
||||
assert await _real_gate_setting(7, "general", _GATE_MIN_BLOCK) == _GATE_MIN_BLOCK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_overlap_floor_has_its_own_lower_clamp():
|
||||
with _setting("0.1"):
|
||||
assert await _real_gate_setting(7, "note_overlap", _GATE_MIN_OVERLAP) == _GATE_MIN_OVERLAP
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unparseable_value_falls_back_to_the_default():
|
||||
with _setting("lots"):
|
||||
got = await _real_gate_setting(7, "snippet", _GATE_MIN_BLOCK)
|
||||
assert got == GATE_DEFAULT_THRESHOLDS["snippet"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unreadable_setting_falls_back_to_the_default():
|
||||
with patch("scribe.services.settings.get_setting",
|
||||
AsyncMock(side_effect=RuntimeError("db down"))):
|
||||
got = await _real_gate_setting(7, "lesson", _GATE_MIN_BLOCK)
|
||||
assert got == GATE_DEFAULT_THRESHOLDS["lesson"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_overlap_floor_never_sits_above_the_block_bar():
|
||||
from scribe.services.dedup import gate_bars
|
||||
|
||||
async def _set(user_id, key, lo):
|
||||
return {"note_copy": 0.85, "note_overlap": 0.90}[key]
|
||||
|
||||
with patch("scribe.services.dedup._gate_setting", AsyncMock(side_effect=_set)):
|
||||
assert await gate_bars(7, "note") == (0.85, 0.85)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_kind_outside_the_copy_band_reads_no_overlap_floor():
|
||||
from scribe.services.dedup import gate_bars
|
||||
|
||||
seen = []
|
||||
|
||||
async def _set(user_id, key, lo):
|
||||
seen.append(key)
|
||||
return GATE_DEFAULT_THRESHOLDS[key]
|
||||
|
||||
with patch("scribe.services.dedup._gate_setting", AsyncMock(side_effect=_set)):
|
||||
assert await gate_bars(7, "process") == (0.90, 0.90)
|
||||
assert seen == ["general"]
|
||||
Reference in New Issue
Block a user