diff --git a/alembic/versions/0116_moment_mappings.py b/alembic/versions/0116_moment_mappings.py new file mode 100644 index 00000000..438b08d1 --- /dev/null +++ b/alembic/versions/0116_moment_mappings.py @@ -0,0 +1,47 @@ +"""moment_mappings — an install's own corrections to which actions reach which +moment (milestone 458 step 2) + +Revision ID: 0116 +Revises: 0115 +Create Date: 2026-10-05 + +The moments and their default actions are code; this table holds what an +install adds ("make ship" is a deliver here) and the defaults it switches off. +User-owned configuration, so it cascades with the user. No CHECK on `effect`, +which the service validates (rule 36). +""" +import sqlalchemy as sa +from alembic import op + +revision = "0116" +down_revision = "0115" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "moment_mappings", + sa.Column("id", sa.BigInteger(), primary_key=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "user_id", sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, + ), + sa.Column("tool", sa.Text(), nullable=False), + sa.Column("match", sa.Text(), nullable=False, server_default=""), + sa.Column("moment", sa.Text(), nullable=False), + sa.Column("effect", sa.Text(), nullable=False, server_default="add"), + sa.Column("reason", sa.Text(), nullable=False, server_default=""), + sa.Column("actor", sa.Text(), nullable=False, server_default="model"), + sa.UniqueConstraint( + "user_id", "tool", "match", "moment", name="uq_moment_mapping", + ), + ) + + +def downgrade() -> None: + op.drop_table("moment_mappings") diff --git a/alembic/versions/0117_rule_moments.py b/alembic/versions/0117_rule_moments.py new file mode 100644 index 00000000..30d3d5ad --- /dev/null +++ b/alembic/versions/0117_rule_moments.py @@ -0,0 +1,38 @@ +"""rule_moments — the moments of work a rule arrives at (milestone 458 step 3) + +Revision ID: 0117 +Revises: 0116 +Create Date: 2026-10-05 + +A rule mounted on a moment is delivered whenever that moment happens, by +lookup rather than by score. The moment is a catalog name, validated by the +service; the catalog is code, so there is no table to key it to. Cascades +with the rule, like rule_systems beside it. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0117" +down_revision = "0116" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "rule_moments", + sa.Column( + "rule_id", sa.BigInteger(), + sa.ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True, + ), + sa.Column("moment", sa.Text(), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + ) + # The delivery read (step 4) asks "which rules are on this moment?", the + # opposite direction from the primary key. + op.create_index("ix_rule_moments_moment", "rule_moments", ["moment"]) + + +def downgrade() -> None: + op.drop_index("ix_rule_moments_moment", table_name="rule_moments") + op.drop_table("rule_moments") diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 25c590e5..5250207a 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -98,6 +98,9 @@ const kbToolRuleThreshold = ref("0.68"); // prose rather than anything a tool produced (#3852). const kbPromptRuleThreshold = ref("0.72"); const kbReportPrefThreshold = ref("0.72"); +// The reply arm (milestone 458): its bar is the bar at which a finished reply +// is HELD for one read, so it sits with the checkpoint, not with the hints. +const kbReplyRuleThreshold = ref("0.8"); // The BUDGETS, one per arm (#4102). Until this step only auto-inject had one // and every other arm's ceiling was a module constant nobody could reach — so // the only control an operator had over a noisy surface was to raise its bar, @@ -108,6 +111,7 @@ const kbRuleHintTopK = ref("5"); const kbToolRuleTopK = ref("5"); const kbPromptRuleTopK = ref("3"); const kbReportPrefTopK = ref("3"); +const kbReplyRuleTopK = ref("1"); // What has been changed about retrieval, newest first — the review surface for // changes the model made on the operator's behalf (#4102). const tuningEvents = ref([]); @@ -313,6 +317,8 @@ async function saveKbInject() { // hold the first command of every session behind whatever ranked first. const cpT = asBar(kbCheckpointThreshold.value, 0.8); const rpT = asBar(kbReportPrefThreshold.value, 0.72); + // A stop bar like the checkpoint's: a fallback of 0 would hold every reply. + const ryT = asBar(kbReplyRuleThreshold.value, 0.8); // 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, @@ -324,11 +330,13 @@ async function saveKbInject() { const trK = asK(kbToolRuleTopK.value, 5); const prK = asK(kbPromptRuleTopK.value, 3); const rpK = asK(kbReportPrefTopK.value, 3); + const ryK = asK(kbReplyRuleTopK.value, 1); kbWritePathTopK.value = String(wpK); kbRuleHintTopK.value = String(rhK); kbToolRuleTopK.value = String(trK); kbPromptRuleTopK.value = String(prK); kbReportPrefTopK.value = String(rpK); + kbReplyRuleTopK.value = String(ryK); kbInjectThreshold.value = String(t); kbInjectTopK.value = String(k); kbDupThresholdSnippet.value = String(dupSnip); @@ -346,6 +354,7 @@ async function saveKbInject() { kbToolRuleThreshold.value = String(trT); kbPromptRuleThreshold.value = String(prT); kbReportPrefThreshold.value = String(rpT); + kbReplyRuleThreshold.value = String(ryT); savingKbInject.value = true; kbInjectSaved.value = false; try { @@ -379,6 +388,9 @@ async function saveKbInject() { // and a constant that lands under the bar is a dead arm, not a quiet // one (#3860). kb_reportpref_threshold: String(rpT), + // The reply backstop's own bar: it HOLDS a reply, so like the + // checkpoint it is never derived from a hint bar. + kb_replyrule_threshold: String(ryT), // The budgets. Every one of these keys is recognised by the server as a // retrieval dial, so this save is recorded in the tuning history as a // change the OPERATOR made — which is the one entry the model must not @@ -388,6 +400,7 @@ async function saveKbInject() { kb_toolrule_top_k: String(trK), kb_promptrule_top_k: String(prK), kb_reportpref_top_k: String(rpK), + kb_replyrule_top_k: String(ryK), kb_duplicate_threshold_snippet: String(dupSnip), kb_duplicate_threshold_note: String(dupNote), kb_duplicate_threshold_task: String(dupTask), @@ -859,6 +872,9 @@ onMounted(async () => { if (allSettings.kb_reportpref_threshold !== undefined) { kbReportPrefThreshold.value = allSettings.kb_reportpref_threshold; } + if (allSettings.kb_replyrule_threshold !== undefined) { + kbReplyRuleThreshold.value = allSettings.kb_replyrule_threshold; + } if (allSettings.kb_writepath_threshold !== undefined) { kbWritePathThreshold.value = allSettings.kb_writepath_threshold; } @@ -882,6 +898,9 @@ onMounted(async () => { if (allSettings.kb_reportpref_top_k !== undefined) { kbReportPrefTopK.value = allSettings.kb_reportpref_top_k; } + if (allSettings.kb_replyrule_top_k !== undefined) { + kbReplyRuleTopK.value = allSettings.kb_replyrule_top_k; + } await loadTuningHistory(); await loadSurfaces(); if (allSettings.kb_duplicate_threshold_snippet !== undefined) { @@ -1919,6 +1938,41 @@ async function deleteUser(userId: number) { />

How many preferences a finished task may be shown (1–10).

+
+ + +

+ The bar at which a finished reply is held for one read: the + reply is searched against every rule's trigger, and a rule this + session has not opened that scores at or above this bar holds the + reply once, naming the rule. It is the backstop for whatever the + earlier checks missed, so it is a stop bar like the one above, not a + hint bar — keep it high. +

+
+
+ + +

How many of the best-scoring rules a reply is checked against (1–10). Only the best can hold the reply; the rest are counted, which is what tells you how close the others came.

+