Compare commits
50
Commits
b267037911
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c4ace5199 | ||
|
|
277f5df515 | ||
|
|
6495236569 | ||
|
|
ab14f783e1 | ||
|
|
1cfbf43ccd | ||
|
|
a165483b92 | ||
|
|
53df6742cb | ||
|
|
e7c1af32a0 | ||
|
|
277aea58e4 | ||
|
|
5e19a1b028 | ||
|
|
be08edcd7e | ||
|
|
7a2aff7bc1 | ||
|
|
0808e8259a | ||
|
|
950c93c5d4 | ||
|
|
21a5831479 | ||
|
|
4a85220aea | ||
|
|
dd1e6e2645 | ||
|
|
14ff41faf5 | ||
|
|
c3ecdf0972 | ||
|
|
1a34363059 | ||
|
|
67df41ae00 | ||
|
|
30d87e461a | ||
|
|
0915c48bb0 | ||
|
|
8be555d6dd | ||
|
|
aea7b63b62 | ||
|
|
48804c437d | ||
|
|
154a5de13e | ||
|
|
5b02908dfd | ||
|
|
2ee24b9d2b | ||
|
|
8b9b3a1d9b | ||
|
|
34cd389371 | ||
|
|
6627cfc2f0 | ||
|
|
238510080e | ||
|
|
8901c904a9 | ||
|
|
70761b16d9 | ||
|
|
8f7f447fda | ||
|
|
111eef7e30 | ||
|
|
8826be7a91 | ||
|
|
e029a7db64 | ||
|
|
9bb59b73ba | ||
|
|
f5a3643da8 | ||
|
|
64cb719a12 | ||
|
|
f1896bfe9d | ||
|
|
ea972ac3f7 | ||
|
|
0d4b155699 | ||
|
|
05da26eb24 | ||
|
|
70d84fbfd7 | ||
|
|
9d8104f7a5 | ||
|
|
7827b4ce63 | ||
|
|
69ce7afc45 |
@@ -46,8 +46,6 @@ on:
|
||||
- "alembic/**"
|
||||
- "alembic.ini"
|
||||
- "Dockerfile"
|
||||
- "assets/**"
|
||||
- "fable-mcp/**"
|
||||
# The plugin ships straight from this repo — installs fetch it via
|
||||
# .claude-plugin/marketplace.json, NOT from the image. So a push here is
|
||||
# the release, with no build step in between. Omitting these paths meant
|
||||
@@ -279,6 +277,21 @@ jobs:
|
||||
env:
|
||||
UV_PROJECT_ENVIRONMENT: /opt/venv
|
||||
run: uv sync --locked --extra dev
|
||||
# Standing answers to the checks carried by rules 81 and 79 — two facts
|
||||
# about THIS runner that conditional rules assert as fact, and that
|
||||
# otherwise need a throwaway job to confirm (#3237). Printing them on
|
||||
# every integration run makes the next rulebook sweep a log read.
|
||||
# Rule 80's evidence is the container listing the next step already
|
||||
# prints. Every command is guarded: a diagnostic that can break the lane
|
||||
# it observes is worse than no diagnostic.
|
||||
- name: Runner facts (rules 79 and 81)
|
||||
run: |
|
||||
echo "--- rule 81: which shell runs a run: step ---"
|
||||
readlink -f /bin/sh || echo "/bin/sh: not a symlink"
|
||||
ps -p $$ -o comm= || true
|
||||
echo "--- rule 79: is a service reachable by its hostname yet? ---"
|
||||
getent hosts postgres \
|
||||
|| echo "no — 'postgres' does not resolve; the bridge-IP lookup is still required"
|
||||
- name: Integration suite (resolve service IP, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
@@ -289,8 +302,9 @@ jobs:
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
test -n "$PG_IP"
|
||||
export DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test"
|
||||
# Wait for Postgres to accept connections (busybox sh — the runner
|
||||
# default — has no bash /dev/tcp, so use Python).
|
||||
# Wait for Postgres to accept connections. The run: shell is dash
|
||||
# (/bin/sh -> /usr/bin/dash on this Debian-based image, confirmed by
|
||||
# the step above) — no bash /dev/tcp, so use Python.
|
||||
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
||||
import socket, sys, time
|
||||
for _ in range(30):
|
||||
@@ -327,6 +341,14 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Rule 149 asks for this on any job deriving the version NAME. The
|
||||
# name here comes from HEAD's commit TIME, which a depth-1 clone
|
||||
# already has — but the rule states it unconditionally because the
|
||||
# failure it guards is silent (a too-low value, every lane green),
|
||||
# and a later change to how the name is derived would inherit the
|
||||
# landmine rather than the guard.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate image tags and version
|
||||
id: tags
|
||||
@@ -339,7 +361,27 @@ jobs:
|
||||
# the runner log on commit 2a374d9.
|
||||
run: |
|
||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
||||
BUILD_VERSION="dev"
|
||||
|
||||
# THREE VALUES, NEVER FOLDED TOGETHER (rule 149). Until 2026-08-31
|
||||
# BUILD_VERSION was the CHANNEL — "dev" / "main" / the tag — so the
|
||||
# image self-reported {"version":"main"}, a channel name where a
|
||||
# build identifier belongs. That cost a debugging session: with the
|
||||
# deploy misbehaving, nothing on the running instance could say
|
||||
# which commit was serving it.
|
||||
|
||||
# 1. ORDERING KEY — BUILD time, monotonic by construction. Minutes
|
||||
# since 2020-01-01. Never a commit count (not monotonic across
|
||||
# branches) and never commit time (goes DOWN when an older
|
||||
# commit is rebuilt).
|
||||
BUILD_KEY=$(( ( $(date -u +%s) - 1577836800 ) / 60 ))
|
||||
|
||||
# 2. NAME — COMMIT time, so the same source reports the same string
|
||||
# on every lane and the channel is the only thing that differs.
|
||||
COMMIT_TS=$(git log --format=%ct -1 HEAD)
|
||||
BUILD_NAME=$(date -u -d "@$COMMIT_TS" +%Y.%m.%d.%H%M)
|
||||
|
||||
# 3. CHANNEL — its own value. Never a suffix, never a segment.
|
||||
CHANNEL="dev"
|
||||
case "${{ github.ref }}" in
|
||||
refs/heads/dev)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||
@@ -348,15 +390,17 @@ jobs:
|
||||
# main IS the production line: publish :latest (plus the :<sha>
|
||||
# set above). No separate :main tag.
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
||||
BUILD_VERSION="main"
|
||||
CHANNEL="stable"
|
||||
;;
|
||||
refs/tags/*)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||
BUILD_VERSION="${{ github.ref_name }}"
|
||||
CHANNEL="stable"
|
||||
;;
|
||||
esac
|
||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "build_name=$BUILD_NAME" >> $GITHUB_OUTPUT
|
||||
echo "build_key=$BUILD_KEY" >> $GITHUB_OUTPUT
|
||||
echo "channel=$CHANNEL" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Free disk space
|
||||
# Self-hosted runner housekeeping. Two-step cleanup:
|
||||
@@ -386,7 +430,15 @@ jobs:
|
||||
push: true
|
||||
provenance: false
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
|
||||
# All three, plus the commit — rule 145: the registry's identity for
|
||||
# a build (:<sha>) and the artifact's identity for itself must
|
||||
# agree, and they can only be checked against each other if the
|
||||
# artifact says which commit it is.
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.tags.outputs.build_name }}
|
||||
BUILD_KEY=${{ steps.tags.outputs.build_key }}
|
||||
BUILD_CHANNEL=${{ steps.tags.outputs.channel }}
|
||||
BUILD_COMMIT=${{ github.sha }}
|
||||
# Registry-backed layer cache. Pull from :cache to prime
|
||||
# BuildKit, push updated layers back to :cache so the next
|
||||
# build starts warm even if the runner's local cache was
|
||||
|
||||
+21
-2
@@ -41,10 +41,29 @@ COPY alembic/ alembic/
|
||||
# Ensure Python finds the source tree (where static files live) before site-packages
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
# Version is injected at build time via --build-arg BUILD_VERSION=YY.MM.DD.N
|
||||
# Falls back to "dev" for local / untagged builds
|
||||
# THREE VALUES, NEVER FOLDED TOGETHER (rule 149), plus the commit.
|
||||
#
|
||||
# BUILD_VERSION is the NAME (YYYY.MM.DD.HHMM, from COMMIT time) — the same
|
||||
# string on every lane for the same source, so it answers "is this the same
|
||||
# code?" rather than "which lane built it?".
|
||||
# BUILD_KEY is the ORDERING KEY (minutes since 2020-01-01, from BUILD time) —
|
||||
# the only value anything may compare to decide what is newer.
|
||||
# BUILD_CHANNEL is its own field. Never a suffix, never a segment of the name.
|
||||
# BUILD_COMMIT lets the artifact's self-report be checked against the :<sha>
|
||||
# it was published under (rule 145).
|
||||
#
|
||||
# Each defaults to empty rather than to a placeholder, EXCEPT the name: a
|
||||
# local build genuinely has no ordering key or channel, and the endpoint says
|
||||
# so by omitting them. Inventing values would make a local image claim a
|
||||
# position in an update order it is not part of.
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG BUILD_KEY=
|
||||
ARG BUILD_CHANNEL=
|
||||
ARG BUILD_COMMIT=
|
||||
ENV APP_VERSION=$BUILD_VERSION
|
||||
ENV APP_BUILD_KEY=$BUILD_KEY
|
||||
ENV APP_CHANNEL=$BUILD_CHANNEL
|
||||
ENV APP_COMMIT=$BUILD_COMMIT
|
||||
|
||||
EXPOSE 5000
|
||||
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: build up down logs health migrate lint typecheck test fmt
|
||||
.PHONY: build up down logs health migrate lint typecheck test fmt mint-plugin
|
||||
|
||||
# --- Docker ---
|
||||
|
||||
@@ -36,3 +36,12 @@ test:
|
||||
|
||||
# Run all checks in one shot (mirrors what CI does)
|
||||
check: lint typecheck test
|
||||
|
||||
# --- Plugin ---
|
||||
|
||||
# Run this after changing anything under plugin/ or .claude-plugin/, BEFORE
|
||||
# committing. The plugin ships straight from git with no build step, so its
|
||||
# version is minted here rather than stamped by CI; the lane fails if you
|
||||
# forget, but this is what makes remembering cheap.
|
||||
mint-plugin:
|
||||
python3 scripts/mint_plugin_version.py
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""add rule_usage_events — was a surfaced rule ever read? (milestone 333 step 1)
|
||||
|
||||
Revision ID: 0094
|
||||
Revises: 0093
|
||||
Create Date: 2026-09-02
|
||||
|
||||
The sibling `note_usage_events` has had since 0071, and the third rule-side
|
||||
table to arrive after `rule_embeddings` and `rule_versions` — each one added
|
||||
because the rule side kept inheriting machinery built for notes and getting
|
||||
the weaker version of it.
|
||||
|
||||
WHAT IT MEASURES. The write-path standing-rule arm is the only retrieval
|
||||
surface in Scribe whose usefulness cannot be observed, and — not coincidentally
|
||||
— the only one that has never declined to fire. Over 30 days it took 296 calls,
|
||||
returned something on every one, and cleared its threshold 100% of the time,
|
||||
while every other surface declines most of the time (#3311). That is either a
|
||||
perfectly tuned surface or a bar it cannot fail to clear, and `retrieval_logs`
|
||||
cannot tell them apart: it records what the ranker scored, never whether the
|
||||
hint was any use.
|
||||
|
||||
WHY NOT A rule_id COLUMN ON note_usage_events. The row shares no note-specific
|
||||
fields and the aggregate readout is the same shape, which is the strongest case
|
||||
for sharing that note #3163 admits. What decides against it is identity at
|
||||
RESTORE: `note_usage_events`'s importer maps `note_id` through `note_id_map`
|
||||
and drops what does not resolve. A rule id parked in that column would come
|
||||
back from a backup silently reattached to whatever note took that number —
|
||||
telemetry not merely lost but wrong, and wrong in a way nothing downstream
|
||||
could detect. `rule_versions` made the same call for the same reason.
|
||||
|
||||
FK-free on `rule_id` and `user_id`, matching note_usage_events, retrieval_logs
|
||||
and app_logs — and deliberately unlike `rule_versions`, which does carry FKs.
|
||||
The difference is what the row is for: a version belongs to a rule's history
|
||||
and dies with it; telemetry outlives the row it describes. Deleting a rule must
|
||||
not erase the evidence that it was surfaced forty times and opened never, since
|
||||
that evidence is exactly the case for having deleted it.
|
||||
|
||||
No CHECK on `event`, matching the note twin. Rule 36 governs adding a value to
|
||||
a column that is already gated; it does not require gating one that never was,
|
||||
and a two-member enum whose members are written by two functions in one module
|
||||
is not where that discipline earns its cost.
|
||||
|
||||
Downgrade drops the table outright. The data is purely observational — nothing
|
||||
reads it for correctness, so losing it costs history and no behaviour.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0094"
|
||||
down_revision = "0093"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"rule_usage_events",
|
||||
# BigInteger throughout where the note twin uses Integer: rules.id is
|
||||
# BigInteger, so rule_id must be, and a high-churn append-only table is
|
||||
# a poor place to discover an id ceiling.
|
||||
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.BigInteger(), nullable=True),
|
||||
sa.Column("rule_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("event", sa.Text(), nullable=False),
|
||||
sa.Column("source", sa.Text(), nullable=False),
|
||||
)
|
||||
# Every readout is "these rule ids, split by event", so the composite is the
|
||||
# one that actually gets used; the others serve pruning and per-user views.
|
||||
op.create_index(
|
||||
"ix_rule_usage_rule_event", "rule_usage_events", ["rule_id", "event"]
|
||||
)
|
||||
op.create_index("ix_rule_usage_created_at", "rule_usage_events", ["created_at"])
|
||||
op.create_index("ix_rule_usage_user_id", "rule_usage_events", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_rule_usage_user_id", table_name="rule_usage_events")
|
||||
op.drop_index("ix_rule_usage_created_at", table_name="rule_usage_events")
|
||||
op.drop_index("ix_rule_usage_rule_event", table_name="rule_usage_events")
|
||||
op.drop_table("rule_usage_events")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""add retrieval_logs.suppressed_count — tell a ranker decline from a repeat (#3497)
|
||||
|
||||
Revision ID: 0095
|
||||
Revises: 0094
|
||||
Create Date: 2026-09-03
|
||||
|
||||
`result_count == 0` has always meant "this surface said nothing", which is the
|
||||
right number for "was the hint any use" and the wrong one for tuning a
|
||||
threshold. It folds together two unrelated events:
|
||||
|
||||
- the ranker found nothing above the bar — the ONLY evidence a threshold is
|
||||
set too high; and
|
||||
- the ranker found something the session had already been shown — a decline
|
||||
that says nothing whatever about the bar.
|
||||
|
||||
The rule arms filter in Python after the search, so they can count the second
|
||||
kind exactly. The note arms pass `exclude_ids` INTO semantic_search_notes, so
|
||||
the dropped rows never come back and there is nothing to count.
|
||||
|
||||
NULLABLE, AND THE NULL IS THE POINT. A surface that does not measure
|
||||
suppression stores NULL, not 0, and the readout renders it as "not measured"
|
||||
rather than "none". Defaulting to 0 would make an unmeasured surface look like
|
||||
a perfectly clean one — the exact substitution of an artifact for a
|
||||
measurement that #3311 made and that #3497 exists to correct. Doing it again,
|
||||
in the migration that fixes it, would be its own small joke.
|
||||
|
||||
No backfill for the same reason: existing rows genuinely do not know, and
|
||||
saying so is the honest state. `retrieval_logs` is not restored from backup,
|
||||
so no importer changes.
|
||||
|
||||
Downgrade drops the column. Purely observational — nothing reads it for
|
||||
correctness.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0095"
|
||||
down_revision = "0094"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"retrieval_logs",
|
||||
sa.Column("suppressed_count", sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("retrieval_logs", "suppressed_count")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""add retrieval_logs.best_available_score — the score the bar rejected (#3670)
|
||||
|
||||
Revision ID: 0096
|
||||
Revises: 0095
|
||||
Create Date: 2026-09-08
|
||||
|
||||
`cleared_threshold` was documented as the number to read FIRST — "a surface
|
||||
that clears its bar on nearly every call is either well-tuned or too loose,
|
||||
and p10 says which". It was never a measurement. The search applies the
|
||||
threshold before returning, so every returned result cleared the bar by
|
||||
construction and a call with no results has no `top_score` to compare:
|
||||
the condition is true exactly when `result_count > 0`.
|
||||
|
||||
`zero_result_calls + cleared_threshold == calls` held on all nineteen
|
||||
source/window readings ever taken. It was `calls - zero_result_calls`
|
||||
wearing a name that promised a second opinion, and a reading procedure was
|
||||
built on top of it that asked the reader to compare a number against itself.
|
||||
|
||||
THE MISSING NUMBER, and the reason this is a column rather than a deletion.
|
||||
The question the table exists to answer is "is the bar in the right place",
|
||||
and that question is only answerable from the calls that returned NOTHING:
|
||||
how close did the best rejected candidate come? A bar at 0.72 turning away
|
||||
a stream of 0.71s is set too high by a hair. A bar turning away 0.30s is
|
||||
doing its job. Those two are indistinguishable today — both render as a
|
||||
zero-result call — and no arrangement of the existing columns separates
|
||||
them, because the losing score is discarded inside the search.
|
||||
|
||||
So the searches now rank without the bar and apply it in Python, which
|
||||
costs nothing (the rows were already ordered by distance, and the qualifying
|
||||
set is provably identical — above-threshold rows sort first), and the best
|
||||
score seen becomes observable.
|
||||
|
||||
NULLABLE, AND UNBACKFILLED, for the reason 0095 spells out: a row written
|
||||
before this shipped genuinely does not know what its best rejected candidate
|
||||
scored, and saying so is the honest state. A 0.0 default would read as "the
|
||||
corpus had nothing remotely relevant" — an artifact standing in for a
|
||||
measurement, which is the whole defect this milestone corrects.
|
||||
|
||||
`retrieval_logs` is not restored from backup, so no importer changes.
|
||||
|
||||
Downgrade drops the column. Purely observational — nothing reads it for
|
||||
correctness.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0096"
|
||||
down_revision = "0095"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"retrieval_logs",
|
||||
sa.Column("best_available_score", sa.Float(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("retrieval_logs", "best_available_score")
|
||||
+19
-6
@@ -7,12 +7,20 @@ import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { apiGet, apiPut } from "@/api/client";
|
||||
import { apiPut } from "@/api/client";
|
||||
import { fetchVersion } from "@/api/version";
|
||||
|
||||
useTheme();
|
||||
|
||||
const router = useRouter();
|
||||
const appVersion = ref("dev");
|
||||
// THREE states, not two (#3127 checklist 12). `null` is "not answered yet" and
|
||||
// renders nothing; a string renders; `appVersionFailed` renders its own thing.
|
||||
// This used to default to the literal "dev" and swallow the error, which meant
|
||||
// an instance that could not answer was indistinguishable from a local build
|
||||
// that genuinely reports "dev" — a blank standing in for `unknown`, in the one
|
||||
// readout whose whole job is to say what is running.
|
||||
const appVersion = ref<string | null>(null);
|
||||
const appVersionFailed = ref(false);
|
||||
const authStore = useAuthStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
|
||||
@@ -119,10 +127,12 @@ onMounted(async () => {
|
||||
startAppServices();
|
||||
}
|
||||
try {
|
||||
const data = await apiGet<{ version: string }>("/api/version");
|
||||
appVersion.value = data.version;
|
||||
appVersion.value = (await fetchVersion()).version;
|
||||
} catch {
|
||||
// silent — version display is non-critical
|
||||
// Not silent any more: the footer says it could not find out, rather than
|
||||
// showing a version it never received. The full readout (version, channel,
|
||||
// commit, build) lives in Settings → Config.
|
||||
appVersionFailed.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -151,7 +161,10 @@ onUnmounted(() => {
|
||||
<div id="main-content" class="app-content">
|
||||
<router-view />
|
||||
</div>
|
||||
<footer class="app-footer">v{{ appVersion }}</footer>
|
||||
<footer class="app-footer">
|
||||
<span v-if="appVersion">v{{ appVersion }}</span>
|
||||
<span v-else-if="appVersionFailed">version unknown</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard shortcuts overlay -->
|
||||
|
||||
+123
-28
@@ -52,41 +52,121 @@ export function apiErrorMessage(e: unknown, fallback: string): string {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path);
|
||||
return handleResponse<T>(res, path);
|
||||
/**
|
||||
* How long an ordinary JSON call may wait before it is declared failed.
|
||||
*
|
||||
* Rule 156: a wait with no deadline is a bug. `fetch`'s own default is to wait
|
||||
* as long as the browser will, which is not a deadline — it is the absence of
|
||||
* one, and it renders as a spinner that never resolves. There is no state a
|
||||
* surface can show for "pending forever" that is not a lie.
|
||||
*
|
||||
* 30s is chosen to be longer than anything healthy: it has to clear a cold
|
||||
* embedding call and a list view under connection-pool contention (#2384 had
|
||||
* /api/projects fanning 25 concurrent sessions at a 15-connection pool), so
|
||||
* tripping it means something is genuinely wrong rather than merely busy. Slow
|
||||
* BY DESIGN is a different case and passes its own value — see the callers in
|
||||
* SettingsView that do.
|
||||
*/
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** HTTP 408. Not a status any Scribe route returns, so it unambiguously means
|
||||
* "the client gave up" rather than anything the server said. */
|
||||
const CLIENT_TIMEOUT_STATUS = 408;
|
||||
|
||||
/**
|
||||
* How long a STREAM may take to answer with its headers.
|
||||
*
|
||||
* Streams are the one case a wall-clock deadline would break: a long-lived SSE
|
||||
* connection is *supposed* to stay open, and `AbortSignal.timeout` would kill
|
||||
* it mid-flight along with the body. But that does not exempt them from rule
|
||||
* 156 — it relocates the deadline. Two different waits are involved:
|
||||
*
|
||||
* connect — the server answering with headers. CAN fail to answer, so it
|
||||
* carries this deadline, cleared the moment headers arrive.
|
||||
* stream — the body, open indefinitely on purpose. Its failure mode is
|
||||
* going quiet, which a timeout cannot tell from being idle; that
|
||||
* is what reconnection and Last-Event-ID are for, not this.
|
||||
*
|
||||
* Reading the connect as exempt because "the stream is long-lived" is the easy
|
||||
* mistake here, and it leaves an unreachable server looking like a quiet one.
|
||||
*/
|
||||
const STREAM_CONNECT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* A signal that aborts if headers do not arrive in time, plus the `settle` to
|
||||
* call once they do. After `settle()` the returned signal never fires, so the
|
||||
* stream body runs unbounded — which is the intent.
|
||||
*/
|
||||
function connectDeadline(base: AbortSignal): { signal: AbortSignal; settle: () => void } {
|
||||
const gate = new AbortController();
|
||||
const timer = setTimeout(
|
||||
() => gate.abort(new DOMException("stream did not connect in time", "TimeoutError")),
|
||||
STREAM_CONNECT_TIMEOUT_MS,
|
||||
);
|
||||
return {
|
||||
signal: AbortSignal.any([base, gate.signal]),
|
||||
settle: () => clearTimeout(timer),
|
||||
};
|
||||
}
|
||||
|
||||
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
export interface RequestOpts {
|
||||
/** Override the deadline. Pass one when the call is slow BY DESIGN. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a request is actually made — every verb below goes through
|
||||
* here, so the deadline cannot be forgotten by adding a sixth.
|
||||
*
|
||||
* EXPIRY SURFACES AS AN `ApiError`, which is rule 156's second half: the
|
||||
* failure has to arrive in the shape the caller already handles. A bare
|
||||
* `DOMException: TimeoutError` would reach `apiErrorMessage(e, fallback)` as
|
||||
* an object with no `body`, so every catch site in the app would report its
|
||||
* generic fallback and the timeout would be invisible in the very situation it
|
||||
* exists to expose. Rethrowing as `ApiError` means ~330 existing call sites
|
||||
* report it correctly without being touched.
|
||||
*
|
||||
* Only a TIMEOUT is converted. A deliberate cancellation aborts with
|
||||
* `AbortError` and is left alone — a caller that cancelled its own request
|
||||
* does not want it reported as a server failure.
|
||||
*/
|
||||
async function request<T>(path: string, init: RequestInit, opts?: RequestOpts): Promise<T> {
|
||||
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === "TimeoutError") {
|
||||
throw new ApiError(CLIENT_TIMEOUT_STATUS, {
|
||||
error: `The server did not answer within ${Math.round(timeoutMs / 1000)}s.`,
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return handleResponse<T>(res, path);
|
||||
}
|
||||
|
||||
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return handleResponse<T>(res, path);
|
||||
/** JSON body headers — the three write verbs sent an identical literal each. */
|
||||
const JSON_HEADERS = { "Content-Type": "application/json" };
|
||||
|
||||
export function apiGet<T>(path: string, opts?: RequestOpts): Promise<T> {
|
||||
return request<T>(path, {}, opts);
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return handleResponse<T>(res, path);
|
||||
export function apiPost<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
||||
return request<T>(path, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
||||
}
|
||||
|
||||
export async function apiDelete(path: string): Promise<void> {
|
||||
const res = await fetch(path, { method: "DELETE" });
|
||||
return handleResponse<void>(res, path);
|
||||
export function apiPut<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
||||
return request<T>(path, { method: "PUT", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
||||
}
|
||||
|
||||
export function apiPatch<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
||||
return request<T>(path, { method: "PATCH", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
||||
}
|
||||
|
||||
export function apiDelete(path: string, opts?: RequestOpts): Promise<void> {
|
||||
return request<void>(path, { method: "DELETE" }, opts);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -221,7 +301,14 @@ export function apiSSEStream(
|
||||
}
|
||||
|
||||
const done = (async () => {
|
||||
const res = await fetch(path, { headers, signal: combinedSignal });
|
||||
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
|
||||
const connect = connectDeadline(combinedSignal);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, { headers, signal: connect.signal });
|
||||
} finally {
|
||||
connect.settle();
|
||||
}
|
||||
if (!res.ok) {
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
@@ -318,11 +405,19 @@ export async function apiStreamPost(
|
||||
body: unknown,
|
||||
onChunk: (data: Record<string, unknown>) => void
|
||||
): Promise<void> {
|
||||
const res = await fetch(path, {
|
||||
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
|
||||
const connect = connectDeadline(new AbortController().signal);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(body),
|
||||
signal: connect.signal,
|
||||
});
|
||||
} finally {
|
||||
connect.settle();
|
||||
}
|
||||
if (!res.ok) {
|
||||
let errBody: Record<string, unknown> = {};
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RecordUsage } from "@/types/usage";
|
||||
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
|
||||
/** How a rule reaches a session (milestone 307). */
|
||||
@@ -96,6 +98,13 @@ export interface RuleHeader {
|
||||
* A date (YYYY-MM-DD), or the literal "never".
|
||||
*/
|
||||
last_verified?: string;
|
||||
/**
|
||||
* Surfaced-vs-opened counts from `rule_usage_events` (milestone 333).
|
||||
* Zero-filled by the list route, so a rule predating the table reads as
|
||||
* "never surfaced" rather than as a missing field — which for a while is
|
||||
* every rule on every install.
|
||||
*/
|
||||
usage?: RecordUsage;
|
||||
}
|
||||
|
||||
export interface ApplicableRules {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RecordUsage } from "@/types/usage";
|
||||
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
|
||||
/** One canonical location of a reusable thing. A snippet that unifies several
|
||||
@@ -50,15 +52,11 @@ export interface Snippet {
|
||||
owner?: string | null;
|
||||
}
|
||||
|
||||
/** How often a record was put in front of an agent versus actually opened.
|
||||
* A high `surfaced_count` with `pull_count: 0` is dead weight — it occupies a
|
||||
* slot in every future auto-inject menu while never being used. */
|
||||
export interface SnippetUsage {
|
||||
surfaced_count: number;
|
||||
pull_count: number;
|
||||
last_surfaced_at: string | null;
|
||||
last_pulled_at: string | null;
|
||||
}
|
||||
/** Kept as a name because every consumer here says "snippet usage" — but it IS
|
||||
* the shared shape, since rules answer the same question off their own table
|
||||
* (milestone 333). The reasoning lives on `RecordUsage`; duplicating the four
|
||||
* fields here is how the two drift. */
|
||||
export type SnippetUsage = RecordUsage;
|
||||
|
||||
/** Result of the last drift check — does the recorded location and code still
|
||||
* match source? The check runs agent-side (Scribe has no checkout); this is the
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { apiGet } from "./client";
|
||||
|
||||
/**
|
||||
* What `/api/version` answers — the client's half of `build_version_payload`
|
||||
* (`src/scribe/routes/api.py`), which is where the reasoning for the shape is
|
||||
* written down.
|
||||
*
|
||||
* EVERY FIELD BUT `version` IS OPTIONAL, and an absent one means "this build
|
||||
* does not know", not "empty". A local build has no ordering key and no
|
||||
* channel, and the server says so by omitting the keys rather than sending
|
||||
* `""` — emitting a placeholder would let it claim a position in an update
|
||||
* order it is not part of.
|
||||
*
|
||||
* So a renderer must read ABSENCE, never falsiness. `build` is a number and
|
||||
* `0` is a legitimate ordering key, so `v.build || "unknown"` would report a
|
||||
* real value as unknown; `v.build ?? "unknown"` is the correct form.
|
||||
*/
|
||||
export interface VersionPayload {
|
||||
/** The NAME — `YYYY.MM.DD.HHMM` from commit time. Answers "is this the same code?" */
|
||||
version: string;
|
||||
/** The ORDERING KEY — minutes since 2020-01-01, from build time. Absent on a local build. */
|
||||
build?: number;
|
||||
/** `dev` / `main` / a tag. Its own field, never folded into the name. */
|
||||
channel?: string;
|
||||
/** The commit the artifact was published under, so its claim can be checked against the registry. */
|
||||
commit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SHORTER than the client's 30s default, deliberately.
|
||||
*
|
||||
* This readout answers "what is running?" during an incident, which is exactly
|
||||
* when the server may be the thing that is unwell — and it is one static field
|
||||
* off a route that does no work, so a healthy instance answers it immediately.
|
||||
* Waiting the full default before saying so would leave a person staring at
|
||||
* "still loading" for half a minute in the moment they are trying to find out
|
||||
* whether the instance is alive at all. Eight seconds clears a slow-but-alive
|
||||
* instance and tells them something quickly when it is not.
|
||||
*/
|
||||
const VERSION_TIMEOUT_MS = 8000;
|
||||
|
||||
export function fetchVersion(): Promise<VersionPayload> {
|
||||
return apiGet<VersionPayload>("/api/version", { timeoutMs: VERSION_TIMEOUT_MS });
|
||||
}
|
||||
@@ -351,3 +351,29 @@
|
||||
|
||||
.required { color: var(--fs-error); }
|
||||
.field-hint { margin: 0.3rem 0 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||||
|
||||
/* --- usage badge ----------------------------------------------------------
|
||||
"surfaced N×, opened M×" on a list row, for any record kind the retrieval
|
||||
surfaces can choose: snippets and notes from note_usage_events, rules from
|
||||
rule_usage_events. Promoted here from SnippetListView's scoped block when
|
||||
the rule list needed the same chip (milestone 333 step 5) — a second scoped
|
||||
copy is how the ninth duplicated CSS family starts (#3207).
|
||||
|
||||
Geometry and colour only. A view keeps its own spacing as a scoped
|
||||
remainder, the way it does for every other recipe in this file. */
|
||||
.usage-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||
color: var(--fs-text-tertiary-fg);
|
||||
}
|
||||
|
||||
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
|
||||
than the danger one, because the record isn't broken, just unearned. */
|
||||
.usage-tag.usage-dead {
|
||||
background: color-mix(in srgb, var(--fs-warning) 18%, transparent);
|
||||
color: var(--fs-warning-fg);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* "N/M used" on a list row — surfaced vs opened, for any record kind.
|
||||
*
|
||||
* Extracted from SnippetListView when the rule list needed the same chip
|
||||
* (milestone 333 step 5). The counts read identically for both; what differs
|
||||
* is the ADVICE, which is why that is a prop. A snippet surfaced repeatedly
|
||||
* and never opened should probably go; a rule in the same position may simply
|
||||
* have a `when_to_apply` that fires on the wrong thing, and telling an
|
||||
* operator to delete it would be the wrong nudge half the time.
|
||||
*/
|
||||
import type { RecordUsage } from "@/types/usage";
|
||||
|
||||
const props = defineProps<{
|
||||
usage?: RecordUsage | null;
|
||||
/** What to suggest when this record looks like dead weight. Appended to the
|
||||
* tooltip; kind-specific, because the remedies are. */
|
||||
deadWeightAdvice: string;
|
||||
/** What the record is called in the tooltip's own sentence. */
|
||||
noun?: string;
|
||||
}>();
|
||||
|
||||
/** Offered repeatedly and never opened. Three rather than one because one or
|
||||
* two surfacings is noise — the record may simply not have come up in a
|
||||
* relevant context yet. */
|
||||
const isDeadWeight = () =>
|
||||
!!props.usage && props.usage.pull_count === 0 && props.usage.surfaced_count >= 3;
|
||||
|
||||
/** "" renders nothing. A record nobody has surfaced yet gets no badge at all:
|
||||
* "0/0" would read as a verdict when it is an absence of evidence — and on a
|
||||
* freshly-migrated install that is every row. */
|
||||
const label = () => {
|
||||
const u = props.usage;
|
||||
if (!u || u.surfaced_count === 0) return "";
|
||||
return `${u.pull_count}/${u.surfaced_count} used`;
|
||||
};
|
||||
|
||||
const title = () => {
|
||||
const u = props.usage;
|
||||
if (!u) return "";
|
||||
const last = u.last_pulled_at
|
||||
? `Last opened ${new Date(u.last_pulled_at).toLocaleDateString()}.`
|
||||
: "Never opened.";
|
||||
const verdict = isDeadWeight() ? ` ${props.deadWeightAdvice}` : "";
|
||||
return (
|
||||
`Surfaced to an agent ${u.surfaced_count}×, opened in full ` +
|
||||
`${u.pull_count}×. ${last}${verdict}`
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="label()"
|
||||
class="usage-tag"
|
||||
:class="{ 'usage-dead': isDeadWeight() }"
|
||||
:title="title()"
|
||||
>{{ label() }}</span>
|
||||
</template>
|
||||
|
||||
<!-- The look lives in components.css (canon). Nothing scoped here on purpose:
|
||||
a view that needs different spacing keeps that as its own remainder. -->
|
||||
@@ -1,5 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { RuleHeader } from "@/api/rulebooks";
|
||||
import UsageBadge from "@/components/UsageBadge.vue";
|
||||
|
||||
/** The dead-weight nudge for a RULE — two remedies, not one, which is the
|
||||
* whole reason this advice is per-kind. A snippet nobody opens should
|
||||
* probably go. A rule nobody opens may be perfectly good and simply firing on
|
||||
* the wrong thing, so "delete it" would be the wrong nudge half the time and
|
||||
* the operator has to be the one who picks. */
|
||||
const RULE_DEAD_WEIGHT =
|
||||
"Kept arriving without being read. Either its trigger fires on the wrong " +
|
||||
"work — reword “when to apply” so it says when — or it is not wanted here. " +
|
||||
"Until one or the other, it takes a slot in every write it matches.";
|
||||
|
||||
defineProps<{ topicId: number; rules: RuleHeader[] }>();
|
||||
const emit = defineEmits<{
|
||||
@@ -28,6 +39,7 @@ const emit = defineEmits<{
|
||||
? 'Asserts a fact nobody has confirmed yet'
|
||||
: `Check last passed ${r.last_verified}`"
|
||||
>{{ r.last_verified === "never" ? "unverified" : `checked ${r.last_verified}` }}</span>
|
||||
<UsageBadge :usage="r.usage" :dead-weight-advice="RULE_DEAD_WEIGHT" />
|
||||
</div>
|
||||
<div class="statement">{{ r.statement }}</div>
|
||||
<div v-if="r.when_to_apply || r.updated_at" class="meta">
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* How often a record was put in front of an agent, and how often one then
|
||||
* opened it in full.
|
||||
*
|
||||
* One shape for every record kind the retrieval surfaces can choose. Snippets
|
||||
* and notes are counted in `note_usage_events`; rules in `rule_usage_events`,
|
||||
* which is a separate table because a note id and a rule id are different
|
||||
* namespaces resolved through different maps at restore (milestone 333). The
|
||||
* TABLES are separate for that reason; the READOUT is the same question, so
|
||||
* the client type is one.
|
||||
*
|
||||
* A high `surfaced_count` with `pull_count: 0` is dead weight — it occupies a
|
||||
* slot in every future menu while never being used. What to DO about that
|
||||
* differs by kind, which is why the advice is a prop on the badge rather than
|
||||
* a property of this type: a snippet nobody opens should probably be deleted,
|
||||
* while a rule nobody opens may just be mis-triggered.
|
||||
*/
|
||||
export interface RecordUsage {
|
||||
surfaced_count: number;
|
||||
pull_count: number;
|
||||
last_surfaced_at: string | null;
|
||||
last_pulled_at: string | null;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
||||
import { fetchVersion, type VersionPayload } from "@/api/version";
|
||||
|
||||
const store = useSettingsStore();
|
||||
const authStore = useAuthStore();
|
||||
@@ -85,6 +86,7 @@ const kbWritePathEnabled = ref(true);
|
||||
// code embeddings sit on a much higher similarity floor than prose, so 0.55 let
|
||||
// unrelated code through (#2223). Shares top-k, not the threshold.
|
||||
const kbWritePathThreshold = ref("0.68");
|
||||
const kbRuleHintThreshold = ref("0.72");
|
||||
// Near-duplicate report floors, one per record kind (services/dedup.py).
|
||||
// Snippets are single-chunk, so their floor sits below the 0.90 write-time
|
||||
// gate and catches what it lets through. Notes/tasks are scored at chunk
|
||||
@@ -147,12 +149,17 @@ async function saveKbInject() {
|
||||
// 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));
|
||||
// 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));
|
||||
kbInjectThreshold.value = String(t);
|
||||
kbInjectTopK.value = String(k);
|
||||
kbDupThresholdSnippet.value = String(dupSnip);
|
||||
kbDupThresholdNote.value = String(dupNote);
|
||||
kbDupThresholdTask.value = String(dupTask);
|
||||
kbWritePathThreshold.value = String(wpT);
|
||||
kbRuleHintThreshold.value = String(rhT);
|
||||
savingKbInject.value = true;
|
||||
kbInjectSaved.value = false;
|
||||
try {
|
||||
@@ -165,6 +172,10 @@ async function saveKbInject() {
|
||||
// measurements that split them.
|
||||
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
|
||||
kb_writepath_threshold: String(wpT),
|
||||
// A THIRD corpus with a third bar — see RULEHINT_DEFAULT_THRESHOLD
|
||||
// in services/plugin_context.py for why rules cannot share the
|
||||
// code threshold any more than code could share the prose one.
|
||||
kb_rulehint_threshold: String(rhT),
|
||||
kb_duplicate_threshold_snippet: String(dupSnip),
|
||||
kb_duplicate_threshold_note: String(dupNote),
|
||||
kb_duplicate_threshold_task: String(dupTask),
|
||||
@@ -187,7 +198,47 @@ const changingPassword = ref(false);
|
||||
const invalidatingSessions = ref(false);
|
||||
const exporting = ref(false);
|
||||
const restoring = ref(false);
|
||||
const appVersion = ref('dev');
|
||||
// Backup, export and restore walk the whole store, so they are slow BY DESIGN
|
||||
// and the client's ordinary 30s default would cut them off mid-work. They are
|
||||
// still bounded: rule 156 asks for a deadline, not a short one, and "no ceiling
|
||||
// at all" is what leaves a restore that died server-side spinning forever.
|
||||
const BULK_TRANSFER_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function bulkDeadline(): AbortSignal {
|
||||
return AbortSignal.timeout(BULK_TRANSFER_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
// ── What's running (#3127 checklist 12) ─────────────────────────────────
|
||||
// Three states kept apart, because collapsing any two of them is the defect
|
||||
// this readout exists to remove: `null` + no error = not asked yet (the Config
|
||||
// tab has not been opened); a payload = answered, with each ABSENT field shown
|
||||
// as "unknown"; `versionError` = the fetch itself failed, which is its own
|
||||
// thing and must never render as a blank or as a plausible-looking value.
|
||||
const versionInfo = ref<VersionPayload | null>(null);
|
||||
const versionLoading = ref(false);
|
||||
const versionError = ref("");
|
||||
const commitCopied = ref(false);
|
||||
|
||||
async function loadVersionPanel() {
|
||||
if (versionLoading.value) return;
|
||||
versionLoading.value = true;
|
||||
versionError.value = "";
|
||||
try {
|
||||
versionInfo.value = await fetchVersion();
|
||||
} catch (e) {
|
||||
versionInfo.value = null;
|
||||
versionError.value = apiErrorMessage(e, "Could not reach the instance to ask what it is running.");
|
||||
} finally {
|
||||
versionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCommit() {
|
||||
if (!versionInfo.value?.commit) return;
|
||||
await copyToClipboard(versionInfo.value.commit);
|
||||
commitCopied.value = true;
|
||||
setTimeout(() => { commitCopied.value = false; }, 2000);
|
||||
}
|
||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
||||
@@ -201,6 +252,7 @@ function _loadTabContent(tab: string) {
|
||||
else if (tab === "logs") loadLogsPanel();
|
||||
else if (tab === "groups") loadGroupsPanel();
|
||||
else if (tab === "areas") canonStore.fetchCatalog(true);
|
||||
else if (tab === "config" && !versionInfo.value) loadVersionPanel();
|
||||
}
|
||||
if (tab === "apikeys") { fetchApiKeys(); }
|
||||
}
|
||||
@@ -554,10 +606,6 @@ function toggleProfileWorkDay(day: string) {
|
||||
function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const v = await apiGet<{ version: string }>('/api/version')
|
||||
appVersion.value = v.version
|
||||
} catch { /* non-critical */ }
|
||||
await store.fetchSettings();
|
||||
newEmail.value = authStore.user?.email ?? "";
|
||||
|
||||
@@ -573,6 +621,9 @@ onMounted(async () => {
|
||||
kbInjectTopK.value = allSettings.kb_autoinject_top_k;
|
||||
}
|
||||
kbWritePathEnabled.value = allSettings.kb_writepath_enabled !== "false";
|
||||
if (allSettings.kb_rulehint_threshold !== undefined) {
|
||||
kbRuleHintThreshold.value = allSettings.kb_rulehint_threshold;
|
||||
}
|
||||
if (allSettings.kb_writepath_threshold !== undefined) {
|
||||
kbWritePathThreshold.value = allSettings.kb_writepath_threshold;
|
||||
}
|
||||
@@ -727,7 +778,7 @@ async function exportData(scope: "user" | "full") {
|
||||
exporting.value = true;
|
||||
try {
|
||||
const url = scope === "full" ? "/api/admin/backup" : "/api/admin/backup?scope=user";
|
||||
const res = await fetch(url);
|
||||
const res = await fetch(url, { signal: bulkDeadline() });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
||||
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
|
||||
@@ -752,7 +803,7 @@ const exportingNotes = ref(false);
|
||||
async function exportNotes(format: "markdown" | "json") {
|
||||
exportingNotes.value = true;
|
||||
try {
|
||||
const res = await fetch(`/api/export?format=${format}`);
|
||||
const res = await fetch(`/api/export?format=${format}`, { signal: bulkDeadline() });
|
||||
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const ext = format === "json" ? "json" : "zip";
|
||||
@@ -981,6 +1032,7 @@ async function handleRestoreFile(event: Event) {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
signal: bulkDeadline(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
||||
@@ -1417,6 +1469,29 @@ async function deleteUser(userId: number) {
|
||||
location, not by resemblance.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="kb-rulehint-threshold">Standing-rule confidence threshold (0–1)</label>
|
||||
<input
|
||||
id="kb-rulehint-threshold"
|
||||
v-model="kbRuleHintThreshold"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="fs-input input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
The same hint can mention a standing rule whose trigger resembles what's
|
||||
being written — only rules marked <em>conditional</em>, since always-on
|
||||
ones are already loaded. Stricter again than the threshold above, because
|
||||
there are far fewer rules than snippets: with a small set, something
|
||||
always ranks first, so the bar has to carry more of the judgement.
|
||||
Raise it if rules keep arriving unread; lower it if a rule you needed
|
||||
never showed up. Settings → check the pull-through in
|
||||
<code>retrieval_telemetry</code> to see which is happening.
|
||||
</p>
|
||||
</div>
|
||||
<!-- A design system belongs to a PROJECT, and the picker for it lives on
|
||||
the project. There was a setting here that designated the system
|
||||
this install's own interface was built from; it only ever described
|
||||
@@ -2109,6 +2184,48 @@ async function deleteUser(userId: number) {
|
||||
<!-- ── Admin ── -->
|
||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>What's running</h2>
|
||||
<p class="section-desc">
|
||||
The build serving this page. Paste the commit into a <code>:sha</code> image
|
||||
lookup to check the registry and the app agree about what was published.
|
||||
</p>
|
||||
|
||||
<div v-if="versionLoading" class="state-msg">Reading the ledger…</div>
|
||||
<div v-else-if="versionError" class="error-msg">
|
||||
{{ versionError }}
|
||||
<button class="btn-ghost btn-compact version-retry" @click="loadVersionPanel">Try again</button>
|
||||
</div>
|
||||
<dl v-else-if="versionInfo" class="version-grid">
|
||||
<dt>Version</dt>
|
||||
<dd class="version-value">{{ versionInfo.version }}</dd>
|
||||
|
||||
<dt>Channel</dt>
|
||||
<dd :class="versionInfo.channel === undefined ? 'version-unknown' : 'version-value'">
|
||||
{{ versionInfo.channel ?? "unknown" }}
|
||||
</dd>
|
||||
|
||||
<dt>Commit</dt>
|
||||
<dd v-if="versionInfo.commit" class="version-value version-commit">
|
||||
<span class="version-sha">{{ versionInfo.commit }}</span>
|
||||
<button class="btn-ghost btn-compact" @click="copyCommit">
|
||||
{{ commitCopied ? "Copied" : "Copy" }}
|
||||
</button>
|
||||
</dd>
|
||||
<dd v-else class="version-unknown">unknown</dd>
|
||||
|
||||
<dt>Build</dt>
|
||||
<!-- The ordering key, kept because its ABSENCE is the diagnostic one:
|
||||
no key means this build is not part of any update order, which is
|
||||
what a local or hand-built image looks like. `??` not `||` — 0 is
|
||||
a legitimate key. -->
|
||||
<dd :class="versionInfo.build === undefined ? 'version-unknown' : 'version-value'">
|
||||
{{ versionInfo.build ?? "unknown" }}
|
||||
</dd>
|
||||
</dl>
|
||||
<div v-else class="empty-msg">Nothing asked yet.</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Application URL</h2>
|
||||
<p class="section-desc">
|
||||
@@ -2768,6 +2885,45 @@ async function deleteUser(userId: number) {
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
/* What's running — a definition list of instance facts. Spacing/geometry only;
|
||||
colour and type come from the tokens. */
|
||||
.version-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.4rem 1rem;
|
||||
margin: 0;
|
||||
align-items: baseline;
|
||||
}
|
||||
.version-grid dt {
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.version-grid dd {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-family: var(--fs-font-mono);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
/* An absent field reads as absent — never as a blank, and never styled to look
|
||||
like a value it does not have (#3127 checklist 12). */
|
||||
.version-grid dd.version-unknown {
|
||||
font-family: inherit;
|
||||
font-style: italic;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.version-commit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.version-sha {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.version-retry {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type SnippetListItem,
|
||||
} from "@/api/snippets";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import UsageBadge from "@/components/UsageBadge.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToastStore();
|
||||
@@ -198,23 +199,6 @@ function languageOf(tags: string[]): string {
|
||||
return tags.find((t) => t && t !== "snippet") ?? "";
|
||||
}
|
||||
|
||||
/** A snippet that has been offered repeatedly and never opened. The threshold
|
||||
* is 3 rather than 1 because one or two surfacings is noise — the record may
|
||||
* simply not have come up in a relevant context yet. */
|
||||
function isDeadWeight(s: SnippetListItem): boolean {
|
||||
const u = s.usage;
|
||||
return !!u && u.pull_count === 0 && u.surfaced_count >= 3;
|
||||
}
|
||||
|
||||
/** Short badge text, or "" to render nothing. A record nobody has surfaced yet
|
||||
* gets no badge at all: "0 / 0" would read as a verdict when it's an absence
|
||||
* of evidence. */
|
||||
function usageBadge(s: SnippetListItem): string {
|
||||
const u = s.usage;
|
||||
if (!u || u.surfaced_count === 0) return "";
|
||||
return `${u.pull_count}/${u.surfaced_count} used`;
|
||||
}
|
||||
|
||||
/** Short label for the drift verdict, or "" when there's nothing to say.
|
||||
* An expired verdict is reported as "unchecked" whatever it used to say —
|
||||
* it was about code that is no longer in the record. */
|
||||
@@ -254,22 +238,13 @@ function driftTitle(s: SnippetListItem): string {
|
||||
return v.detail ? `${when}: ${what}. ${v.detail}` : `${when}: ${what}.`;
|
||||
}
|
||||
|
||||
function usageTitle(s: SnippetListItem): string {
|
||||
const u = s.usage;
|
||||
if (!u) return "";
|
||||
const last = u.last_pulled_at
|
||||
? `Last opened ${new Date(u.last_pulled_at).toLocaleDateString()}.`
|
||||
: "Never opened.";
|
||||
const verdict = isDeadWeight(s)
|
||||
? " Offered repeatedly without ever being opened — consider rewriting its" +
|
||||
" “when to reach for it” so it says when, or deleting it. It takes a slot" +
|
||||
" in every future auto-inject menu."
|
||||
: "";
|
||||
return (
|
||||
`Surfaced to an agent ${u.surfaced_count}×, opened in full ` +
|
||||
`${u.pull_count}×. ${last}${verdict}`
|
||||
);
|
||||
}
|
||||
/** The dead-weight nudge for a SNIPPET, passed to the shared badge. Kept here
|
||||
* rather than inside the component because the remedy is kind-specific — a
|
||||
* rule in the same position gets different advice (milestone 333 step 5). */
|
||||
const SNIPPET_DEAD_WEIGHT =
|
||||
"Offered repeatedly without ever being opened — consider rewriting its " +
|
||||
"“when to reach for it” so it says when, or deleting it. It takes a slot " +
|
||||
"in every future auto-inject menu.";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -456,14 +431,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
<span v-if="driftBadge(s)" class="drift-tag" :title="driftTitle(s)">
|
||||
{{ driftBadge(s) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="usageBadge(s)"
|
||||
class="usage-tag"
|
||||
:class="{ 'usage-dead': isDeadWeight(s) }"
|
||||
:title="usageTitle(s)"
|
||||
>
|
||||
{{ usageBadge(s) }}
|
||||
</span>
|
||||
<UsageBadge :usage="s.usage" :dead-weight-advice="SNIPPET_DEAD_WEIGHT" />
|
||||
<span v-if="s.shared" class="shared-tag" :title="`Shared by ${s.owner ?? 'another user'} — a suggestion, not your own record`">
|
||||
by {{ s.owner ?? "another user" }}
|
||||
</span>
|
||||
@@ -757,23 +725,6 @@ function usageTitle(s: SnippetListItem): string {
|
||||
color: var(--fs-error-fg);
|
||||
}
|
||||
|
||||
.usage-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||
color: var(--fs-text-tertiary-fg);
|
||||
}
|
||||
|
||||
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
|
||||
than the danger one, because the record isn't broken, just unearned. */
|
||||
.usage-tag.usage-dead {
|
||||
background: color-mix(in srgb, var(--fs-warning) 18%, transparent);
|
||||
color: var(--fs-warning-fg);
|
||||
}
|
||||
|
||||
/* Header + select-mode */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "scribe",
|
||||
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||
"version": "0.1.48",
|
||||
"version": "2026.09.09.0408",
|
||||
"author": {
|
||||
"name": "Bryan Van Deusen"
|
||||
},
|
||||
|
||||
+7
-2
@@ -78,8 +78,13 @@ On install you'll be asked for:
|
||||
|
||||
## Notes
|
||||
|
||||
- Set a `version` bump in `.claude-plugin/plugin.json` per release so clients
|
||||
pick up changes.
|
||||
- **Do not hand-edit `version` in `.claude-plugin/plugin.json`.** It is minted
|
||||
from the clock — run `python3 scripts/mint_plugin_version.py` (or `make
|
||||
mint-plugin`, where `make` is installed) after changing anything under
|
||||
`plugin/`, and commit the result. The installer decides whether to refresh the cache it
|
||||
executes from by comparing that string, so content that ships without a new
|
||||
version reaches the repo and stops there (#2209). CI fails the lane if you
|
||||
forget.
|
||||
- The session-start, auto-inject and prior-art hooks need only a **read**-scoped
|
||||
key; the MCP tools need **write** scope to create/update. Every hook is a GET
|
||||
for that reason — a read key cannot POST.
|
||||
|
||||
@@ -33,6 +33,15 @@
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_prior_art.sh\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_tool_rules.sh\""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
|
||||
@@ -55,6 +55,53 @@ here=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) || exit 0
|
||||
event=$(cat 2>/dev/null || true)
|
||||
source=$(printf '%s' "$event" | jq -r '.source // empty' 2>/dev/null) || source=""
|
||||
|
||||
# --- The rule ledger outlives the context it describes (#3749) ---
|
||||
#
|
||||
# scribe_prior_art.sh and scribe_tool_rules.sh record every rule id they have
|
||||
# named in <state>/<sid>.rules.ids and hand it back as exclude_rule_ids, so a
|
||||
# rule is named once per session and then goes quiet. That is right while the
|
||||
# session still HOLDS what it was told, and wrong the moment it does not.
|
||||
#
|
||||
# A compaction summarizes the earlier injections away and does not touch the
|
||||
# filesystem, so the rule ends up absent from context AND still excluded —
|
||||
# unreachable for the rest of the session. The banner below tells the model to
|
||||
# re-pull its ALWAYS-ON rules, but a rule an arm surfaced is conditional and is
|
||||
# not in that set, so it has no other way back. The rules most likely to be in
|
||||
# this state are the ones that fire most often, which is to say the ones that
|
||||
# apply most.
|
||||
#
|
||||
# The session id survives a compaction — the etag marker further down is
|
||||
# rewritten on `compact` and keyed by session_id, which is only meaningful if
|
||||
# the id is stable — so the stale ledger is genuinely found again, not orphaned.
|
||||
#
|
||||
# CLEARED ON THE SOURCES THAT DESTROY CONTEXT, AND ONLY THOSE:
|
||||
#
|
||||
# compact CLEAR — summarized away; the file survived.
|
||||
# clear CLEAR — context wiped.
|
||||
# startup nothing to do: a new session id means a new, empty file.
|
||||
# resume KEEP. The context was genuinely restored, so the ledger still
|
||||
# describes what the session holds. Clearing here would re-surface
|
||||
# every rule after a restore that lost nothing — the mirror error.
|
||||
# fork KEEP, and the answer is the same whichever way forks are keyed: a
|
||||
# fork carries the conversation, so if it inherits the id the ledger
|
||||
# is accurate, and if it gets a new one the file is empty anyway.
|
||||
#
|
||||
# ONLY the rules ledger. The same directory holds .ids / .sync.ids /
|
||||
# .derive.ids for the note arms. Whether a surfaced NOTE should return after a
|
||||
# compaction is a different question with a different answer, and leaving those
|
||||
# alone is a decision rather than an oversight.
|
||||
case "$source" in
|
||||
compact|clear)
|
||||
sid=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || sid=""
|
||||
if [ -n "$sid" ]; then
|
||||
safe_sid=$(printf '%s' "$sid" | tr -c 'A-Za-z0-9._-' '_')
|
||||
# Best-effort, like every other filesystem touch in these hooks: a ledger
|
||||
# that cannot be removed costs a repeated exclusion, never a session.
|
||||
rm -f "${TMPDIR:-/tmp}/scribe-priorart/${safe_sid}.rules.ids" 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
out=""
|
||||
# Append $1 to $out, separated by a horizontal rule when $out already has content.
|
||||
append() { if [ -n "$out" ]; then out="${out}"$'\n\n---\n\n'"$1"; else out="$1"; fi; }
|
||||
|
||||
@@ -21,6 +21,16 @@ for the operator's work, and as your own working memory across sessions.
|
||||
compaction — call `list_always_on_rules()` (and `enter_project()` when a
|
||||
project is in scope) BEFORE acting. When a loaded rule and a default habit
|
||||
disagree, the rule wins; if no rule speaks to it, ask rather than assume.
|
||||
- **What you loaded is not all of the rules.** Only the always-on tier arrives
|
||||
that way; conditional rules are RETRIEVED, and one you were never handed
|
||||
binds exactly as hard. So before a consequential act, `search` for a rule
|
||||
about it (`content_type="rule"`) rather than concluding from an empty
|
||||
loaded set that nothing applies. "I was not told" is not the same as "there
|
||||
is no rule," and only one of those is checkable.
|
||||
This bites hardest on which TOOL to reach for — curling an API that has an
|
||||
MCP client, standing up a local stack, running a suite CI owns. Those feel
|
||||
like mechanics rather than decisions, so they raise no doubt and generate no
|
||||
query; the moment you are most confident is the moment to look.
|
||||
- **Recall before acting** — before you answer anything about the operator's
|
||||
work or start a task, `search` Scribe first; assume a related note, task, or
|
||||
decision already exists. Concretely, reach for recall whenever a request
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# Scribe — PreToolUse rule arm for ACTIONS (#3476).
|
||||
#
|
||||
# The sibling of scribe_prior_art.sh. That hook is registered on Write|Edit and
|
||||
# asks "what is recorded about the file being written". This one asks "does a
|
||||
# standing rule speak to the command about to be run" — the question nothing
|
||||
# could ask before, and the reason every rule about which tool to reach for had
|
||||
# to live in the always-on preload instead.
|
||||
#
|
||||
# WHY A HOOK AND NOT AN INSTRUCTION. A reflex generates no query (note #3089):
|
||||
# you reach for `curl` confidently, with no moment of doubt, so a surface that
|
||||
# waits to be asked never fires. Here nothing is asked — the tool call IS the
|
||||
# query, and the reflex has to become a tool call before it can do anything.
|
||||
#
|
||||
# SILENT ON OUTAGE, deliberately, unlike the prior-art hook. A write is
|
||||
# occasional; a Bash call is not, and an "instance did not answer" line before
|
||||
# every command is the noise that gets a channel muted. scribe_prior_art.sh
|
||||
# still speaks for both when the instance is down.
|
||||
#
|
||||
# Env:
|
||||
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
||||
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
command -v curl >/dev/null 2>&1 || exit 0
|
||||
|
||||
# PreToolUse delivers { session_id, cwd, tool_name, tool_input: {...}, ... }
|
||||
event=$(cat 2>/dev/null || true)
|
||||
tool_name=$(printf '%s' "$event" | jq -r '.tool_name // empty' 2>/dev/null) || exit 0
|
||||
session_id=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
|
||||
event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_cwd=""
|
||||
|
||||
[ -n "$tool_name" ] || exit 0
|
||||
|
||||
# The action, as text. `.command` is Bash's field; the fallbacks let the matcher
|
||||
# in hooks.json widen to other tools without this script changing — which is the
|
||||
# whole reason the server side takes a name and a string rather than a schema.
|
||||
command_text=$(printf '%s' "$event" | jq -r '
|
||||
.tool_input.command //
|
||||
.tool_input.url //
|
||||
.tool_input.prompt //
|
||||
empty' 2>/dev/null) || command_text=""
|
||||
|
||||
[ -n "$command_text" ] || exit 0
|
||||
|
||||
# shellcheck source=plugin/hooks/scribe_defs.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
||||
|
||||
# scribe_config, not a hand-rolled pair of parameter expansions: it also treats
|
||||
# an UNEXPANDED `${...}` placeholder as unset, which would otherwise be sent as
|
||||
# a garbage Bearer token and 401 on every call (#2198's class).
|
||||
scribe_config || exit 0
|
||||
|
||||
# Bounded before encoding: a heredoc or a pasted script can be enormous, and
|
||||
# the verb and its target — the part a rule is about — sit at the front. The
|
||||
# server bounds it again; this keeps a huge payload off the wire in the first
|
||||
# place. `head -c`, never `cut -c`: cut truncates each LINE and caps nothing.
|
||||
command_text=$(printf '%s' "$command_text" | head -c 2000)
|
||||
|
||||
# -sRr, never -rR: jq -R without -s reads LINE BY LINE, so a multi-line command
|
||||
# would encode per line and join with raw newlines — an invalid URL.
|
||||
cmd_enc=$(printf '%s' "$command_text" | jq -sRr '@uri' 2>/dev/null) || exit 0
|
||||
tool_enc=$(printf '%s' "$tool_name" | jq -sRr '@uri' 2>/dev/null) || exit 0
|
||||
|
||||
repo_q=""
|
||||
lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
||||
repo_remote=$(git -C "$lookup_dir" remote get-url origin 2>/dev/null || true)
|
||||
if [ -n "$repo_remote" ]; then
|
||||
repo_enc=$(printf '%s' "$repo_remote" | jq -sRr '@uri' 2>/dev/null) || repo_enc=""
|
||||
[ -n "$repo_enc" ] && repo_q="&repo=${repo_enc}"
|
||||
fi
|
||||
|
||||
# THE SHARED SESSION LEDGER, and the thing most worth getting right here.
|
||||
#
|
||||
# scribe_prior_art.sh keeps the rules it has already named in
|
||||
# <state>/<sid>.rules.ids and passes them as exclude_rule_ids. This hook reads
|
||||
# and appends to that SAME file rather than keeping its own: two ledgers would
|
||||
# mean a rule named by one arm gets re-offered by the other, and the hint that
|
||||
# fires most often is exactly the one that must not repeat itself.
|
||||
#
|
||||
# The directory keeps the prior-art name on purpose — renaming it would orphan
|
||||
# every live session's state for a cosmetic gain.
|
||||
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
||||
mkdir -p "$state_dir" 2>/dev/null || true
|
||||
rulefile=""
|
||||
rule_exclude_q=""
|
||||
if [ -n "$session_id" ]; then
|
||||
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
||||
rulefile="$state_dir/${safe_sid}.rules.ids"
|
||||
if [ -f "$rulefile" ]; then
|
||||
rule_seen=$(tr '\n' ',' < "$rulefile" 2>/dev/null | sed 's/,$//')
|
||||
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# `|| exit 0` here, unlike the prior-art hook: there is no local arm whose
|
||||
# finding would be discarded, and an outage line before every command is worse
|
||||
# than silence. See the header.
|
||||
body=$(curl -fsS --max-time 5 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/tool-rules?tool=${tool_enc}&command=${cmd_enc}${repo_q}${rule_exclude_q}" 2>/dev/null) || exit 0
|
||||
|
||||
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
|
||||
[ -n "$context" ] || exit 0
|
||||
|
||||
# Remember what was named so it is not repeated this session.
|
||||
if [ -n "$rulefile" ]; then
|
||||
printf '%s' "$body" | jq -r '(.rule_ids // [])[]?' 2>/dev/null >> "$rulefile" || true
|
||||
fi
|
||||
|
||||
jq -cn --arg ctx "$context" '{
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse",
|
||||
additionalContext: $ctx
|
||||
}
|
||||
}' 2>/dev/null || true
|
||||
exit 0
|
||||
@@ -56,10 +56,31 @@ Two constraints on *how* that's achieved:
|
||||
re-deriving it or opening a duplicate. When a project is in scope, pass its
|
||||
`project_id` so results stay scoped.
|
||||
|
||||
2. **Standing rules are binding.** Load them via `list_always_on_rules()` at
|
||||
session start (see "Do this first"); treat every one as binding. Pull a
|
||||
rule's full statement with `get_rule(id)` when it's about to bite. When a
|
||||
project is in scope, `enter_project(id)` also returns its applicable rules.
|
||||
2. **Standing rules are binding — and the ones you were handed are not all of
|
||||
them.** Load the resident set via `list_always_on_rules()` at session start
|
||||
(see "Do this first"); treat every one as binding. Pull a rule's full
|
||||
statement with `get_rule(id)` when it's about to bite. When a project is in
|
||||
scope, `enter_project(id)` also returns its applicable rules.
|
||||
|
||||
Rules come in two tiers. **Always-on** rules are delivered — they arrive
|
||||
whether or not you ask. **Conditional** rules are RETRIEVED, and one binds
|
||||
just as hard for never having been handed to you. So before a consequential
|
||||
act, `search(content_type="rule")` on what you are about to do. An empty
|
||||
loaded set is not evidence that no rule applies; it is only evidence that
|
||||
none was pushed, and those are different claims.
|
||||
|
||||
The tier split exists because delivery does not scale: every resident rule
|
||||
costs tokens in every session forever, so a rulebook that grows past a few
|
||||
dozen either stops growing or stops fitting. Retrieval is what lets the
|
||||
rulebook keep growing — but retrieval only fires if something asks.
|
||||
|
||||
**Ask hardest where you feel most certain.** Rules about which TOOL to reach
|
||||
for — use the forge's MCP client rather than curling its API, don't stand up
|
||||
a local stack, don't run the suite CI owns — govern moves that feel like
|
||||
mechanics rather than decisions. A reflex raises no doubt, so it generates
|
||||
no query, so the rule that would have stopped it is never retrieved. That is
|
||||
the failure this instruction exists to prevent, and confidence is its only
|
||||
warning sign.
|
||||
|
||||
3. **Update over duplicate.** When recording, prefer updating an existing
|
||||
note/rule/task over creating a new one. Search first; revise what's there.
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bump the patch segment of fable-mcp/pyproject.toml version and stage the file.
|
||||
# Usage: called automatically by the Claude Code pre-commit hook, or manually.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
FILE="$REPO_ROOT/fable-mcp/pyproject.toml"
|
||||
|
||||
current=$(grep '^version = ' "$FILE" | sed 's/version = "\(.*\)"/\1/')
|
||||
major=$(echo "$current" | cut -d. -f1)
|
||||
minor=$(echo "$current" | cut -d. -f2)
|
||||
patch=$(echo "$current" | cut -d. -f3)
|
||||
new_version="$major.$minor.$((patch + 1))"
|
||||
|
||||
sed -i "s/^version = \"$current\"/version = \"$new_version\"/" "$FILE"
|
||||
git -C "$REPO_ROOT" add "$FILE"
|
||||
echo "fable-mcp: $current → $new_version"
|
||||
+277
-44
@@ -13,9 +13,20 @@ separate defects have reached a live install through that path:
|
||||
install, because `plugin.json`'s version wasn't bumped and the installer
|
||||
compares versions to decide whether to refresh its cache.
|
||||
|
||||
The rule for the second one was already written down and was still missed. A
|
||||
written rule that depends on being remembered during a long session is not a
|
||||
control; this is.
|
||||
Both were fixed. The second was fixed TWICE — once by bumping the number, and
|
||||
then properly, by removing the class it came from: `plugin.json`'s version is
|
||||
no longer a value anybody chooses. `scripts/mint_plugin_version.py` derives it
|
||||
from the clock (`make mint-plugin`), and `check_version_is_minted` below fails
|
||||
the lane when shipped content moved and the version did not.
|
||||
|
||||
State exactly what that did and did not remove, because a rationale that
|
||||
overstates its own control is how the control gets trusted past its limit, and
|
||||
because the paragraph this replaces was itself read that way. Gone: having to
|
||||
remember which NUMBER to write, and the whole question of whether a chosen
|
||||
number was the right one. Not gone: the mint still has to be RUN, and
|
||||
forgetting to run it is still possible. What changed is that forgetting is now
|
||||
LOUD — a red lane on the batch that forgot, instead of a silent no-op found
|
||||
weeks later when somebody says "I don't think it updated" (#2220).
|
||||
|
||||
shellcheck and jq are NOT in `ci-python` (verified against CI-runner's Dockerfile
|
||||
and scripts/install-common.sh, not from memory — rule #37). CI installs both
|
||||
@@ -32,7 +43,14 @@ whole file exists to prevent.
|
||||
|
||||
Usage:
|
||||
python3 scripts/check_plugin.py # all checks
|
||||
python3 scripts/check_plugin.py --no-version # skip the bump check
|
||||
python3 scripts/check_plugin.py --no-version # on `main` only — see below
|
||||
|
||||
`--no-version` exists for ONE case. The version is measured against
|
||||
`origin/main`, so on `main` itself the comparison is against itself and answers
|
||||
nothing; the syntax, pattern and marker checks are the only ones that mean
|
||||
anything there. It is NOT a way past a red lane — see
|
||||
`check_version_is_minted`, whose whole design is shaped by keeping this flag
|
||||
out of anyone's muscle memory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -43,16 +61,90 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
# The shape contract is ONE definition, shared with the script that mints it —
|
||||
# a checker carrying its own copy of the format would drift from the minter
|
||||
# and pass values the minter can no longer produce. Explicit path insert
|
||||
# because this file runs both as `python3 scripts/check_plugin.py` (which puts
|
||||
# `scripts/` on the path, not the root) and as an import from the test suite.
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from scripts.mint_plugin_version import VERSION_RE # noqa: E402
|
||||
|
||||
PLUGIN_DIR = ROOT / "plugin"
|
||||
HOOKS_DIR = PLUGIN_DIR / "hooks"
|
||||
MANIFEST = PLUGIN_DIR / ".claude-plugin" / "plugin.json"
|
||||
|
||||
# Paths whose contents reach an install. Keep in step with the workflow's
|
||||
# `paths:` filter — a path that ships but isn't checked here is the gap again.
|
||||
SHIPPED = ("plugin", ".claude-plugin")
|
||||
# ── What ships, and what decides what it says about itself ─────────────────
|
||||
#
|
||||
# ONE definition (#3127 §3, milestone 334 step 2). It has TWO consumers that
|
||||
# need different granularities, and conflating them is the bug:
|
||||
#
|
||||
# the workflow's `paths:` trigger whole paths should CI run at all?
|
||||
# the version check paths MINUS should the version
|
||||
# the manifest have moved?
|
||||
# `version`
|
||||
#
|
||||
# The second one is why this is not just a tuple of paths. `plugin.json` lives
|
||||
# INSIDE `plugin/`, so a version bump is itself a change to the shipped set —
|
||||
# and a check that reads the set naively then treats the bump as its own
|
||||
# justification. Any bump passes, no bump fails, and it has proved nothing.
|
||||
# `shipped_content_changed` below is the exclusion-aware reader.
|
||||
#
|
||||
# The exclusion is that ONE FIELD, never the whole file: `plugin.json` also
|
||||
# carries description, mcpServers and userConfig, all of which reach an
|
||||
# install and all of which matter. Excluding the file wholesale would mean a
|
||||
# userConfig-only edit computes an unchanged version and never refreshes —
|
||||
# #2209 again with a narrower trigger.
|
||||
SHIPPED_PATHS = ("plugin", ".claude-plugin")
|
||||
|
||||
# Files that decide what a published artifact SAYS ABOUT ITSELF — kept as a
|
||||
# table so the next artifact is a one-line addition rather than a third
|
||||
# bespoke guard (#3127 §3). The membership test is NOT "is this copied into
|
||||
# the artifact?" but "can changing this file change the published bytes, or
|
||||
# what the artifact says about itself?" — FC learned that twice in four days
|
||||
# (#3156, #3202), and a deriver is never in the COPY list.
|
||||
#
|
||||
# Note what is absent: a CHECKER does not belong here. Whatever validates a
|
||||
# version decides whether the lane goes red, not what any artifact reports,
|
||||
# so `check_plugin.py` itself is not a deriver, while the script that mints
|
||||
# the plugin version is.
|
||||
DERIVERS: dict[str, tuple[str, ...]] = {
|
||||
# The "Generate image tags and version" step computes the server image's
|
||||
# name, ordering key and channel (#3298).
|
||||
".forgejo/workflows/ci.yml": ("server-image",),
|
||||
# Decides the plugin's version FORMAT, so it decides what every future
|
||||
# manifest says about itself (milestone 334 step 3).
|
||||
"scripts/mint_plugin_version.py": ("plugin",),
|
||||
}
|
||||
|
||||
|
||||
def version_relevant_paths() -> tuple[str, ...]:
|
||||
"""Everything a change to which must produce a NEW plugin version.
|
||||
|
||||
Wider than `SHIPPED_PATHS`, and #3127 §3's asymmetry is why it has to be:
|
||||
|
||||
A change to how the VERSION is computed is compared against nothing at
|
||||
all. Left out, the published artifact goes on reporting the OLD value
|
||||
indefinitely.
|
||||
|
||||
Concretely — change the mint script's format string, change nothing else,
|
||||
and a diff over the shipped paths alone reports "no content change, the
|
||||
version need not move". The manifest then keeps a value in the old format
|
||||
forever and nothing ever says so. The mint script reaches no install and
|
||||
belongs here anyway; that is #3156's exact shape.
|
||||
|
||||
A CHECKER is deliberately not here. Whatever validates the version decides
|
||||
whether the lane goes red, not what any artifact reports — so this file is
|
||||
absent from its own set, and that is not an oversight.
|
||||
"""
|
||||
return SHIPPED_PATHS + tuple(
|
||||
path for path, artifacts in DERIVERS.items() if "plugin" in artifacts
|
||||
)
|
||||
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
@@ -143,8 +235,6 @@ def check_patterns() -> None:
|
||||
ok(f"{rel}: no known-bad patterns")
|
||||
|
||||
|
||||
# --- the version bump ------------------------------------------------------
|
||||
|
||||
# --- shellcheck ------------------------------------------------------------
|
||||
|
||||
def check_shellcheck() -> None:
|
||||
@@ -215,6 +305,16 @@ SMOKE_EVENTS: dict[str, str] = {
|
||||
"tool_input": {"file_path": "src/x.py",
|
||||
"new_string": f"def {_ABSENT_SYM}():\n pass\n"}}
|
||||
),
|
||||
# The pre-tool rule arm (#3476). A real Bash call, and one whose whole
|
||||
# point is that it looks harmless: reaching for curl against the forge API
|
||||
# is the reflex the arm exists to catch. With no instance it must stay
|
||||
# SILENT — it is deliberately not an OUTAGE_SPEAKER, because a Bash call is
|
||||
# not occasional and an outage line before every command gets the channel
|
||||
# muted.
|
||||
"scribe_tool_rules.sh": json.dumps(
|
||||
{"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
|
||||
"tool_input": {"command": "curl -s https://example.invalid/api/v1/runs"}}
|
||||
),
|
||||
"scribe_sync_processes.sh": json.dumps({"source": "startup"}),
|
||||
"scribe_session_context.sh": json.dumps({"source": "startup"}),
|
||||
# The after-write hook (#2901) diffs the working tree; on CI's clean
|
||||
@@ -391,80 +491,213 @@ def _git(*args: str) -> tuple[int, str]:
|
||||
return proc.returncode, (proc.stdout or proc.stderr).strip()
|
||||
|
||||
|
||||
def manifest_version(ref: str | None = None) -> str | None:
|
||||
"""The manifest version at `ref`, or in the working tree when ref is None."""
|
||||
def manifest_text(ref: str | None = None) -> str | None:
|
||||
"""The manifest's RAW TEXT at `ref`, or in the working tree when ref is None.
|
||||
|
||||
Split out from `manifest_version` because the exclusion below needs every
|
||||
field except one, not the one field.
|
||||
"""
|
||||
if ref is None:
|
||||
try:
|
||||
return json.loads(MANIFEST.read_text()).get("version")
|
||||
except Exception:
|
||||
return MANIFEST.read_text()
|
||||
except OSError:
|
||||
return None
|
||||
rel = MANIFEST.relative_to(ROOT).as_posix()
|
||||
code, out = _git("show", f"{ref}:{rel}")
|
||||
if code != 0:
|
||||
return out if code == 0 else None
|
||||
|
||||
|
||||
def manifest_version(ref: str | None = None) -> str | None:
|
||||
"""The manifest version at `ref`, or in the working tree when ref is None."""
|
||||
text = manifest_text(ref)
|
||||
if text is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(out).get("version")
|
||||
return json.loads(text).get("version")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def check_version_bump(base: str = "origin/main") -> None:
|
||||
"""If shipped plugin content differs from `base`, the version must too.
|
||||
# Distinct from None, which is a legitimate "this manifest does not exist".
|
||||
_UNREADABLE = object()
|
||||
|
||||
Stated against the BASE BRANCH rather than the last commit on purpose. A
|
||||
per-commit rule would demand a bump from every commit in a batch; what
|
||||
actually matters is that whatever reaches an install carries a version the
|
||||
installer can tell apart from the one already cached. One bump per batch,
|
||||
which is also how a human would do it.
|
||||
|
||||
def manifest_differs_beyond_version(a: str | None, b: str | None) -> bool:
|
||||
"""Do two `plugin.json` texts differ in anything OTHER than `version`?
|
||||
|
||||
THE exclusion, and it is kept pure — no git, no filesystem — because this
|
||||
is the half worth testing hard and it needs no repository to exercise.
|
||||
|
||||
Compares PARSED objects rather than text, so reformatting, key reordering
|
||||
and whitespace do not read as content changes. `version` is dropped from
|
||||
both sides; everything else counts, which is what keeps a userConfig-only
|
||||
or mcpServers-only edit demanding a new version.
|
||||
|
||||
Unreadable input answers True. The conservative direction is "demand a new
|
||||
version": a spurious bump costs one cache refresh, while a missed one is
|
||||
#2209 — the fix reaches the repo and stops there.
|
||||
"""
|
||||
def without_version(text: str | None):
|
||||
if text is None:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except Exception:
|
||||
return _UNREADABLE
|
||||
if not isinstance(data, dict):
|
||||
return _UNREADABLE
|
||||
return {k: v for k, v in data.items() if k != "version"}
|
||||
|
||||
left, right = without_version(a), without_version(b)
|
||||
if left is _UNREADABLE or right is _UNREADABLE:
|
||||
return True
|
||||
return left != right
|
||||
|
||||
|
||||
def shipped_content_changed(base: str) -> tuple[bool | None, list[str]]:
|
||||
"""Has anything that REACHES AN INSTALL changed against `base`?
|
||||
|
||||
Returns `(changed, paths)`. `changed` is **None** when the question could
|
||||
not be answered — a caller must never read that as "no", which is the
|
||||
distinction #2663 cost weeks of zeroed telemetry to learn.
|
||||
|
||||
The manifest is special-cased, not excluded: if it is the ONLY thing that
|
||||
moved and the only difference is `version`, nothing that reaches an
|
||||
install has changed. Any other manifest field, or any other file, counts.
|
||||
|
||||
Reads `version_relevant_paths`, which is the shipped set PLUS the files
|
||||
that decide the version — see there for why the deriver has to be in it.
|
||||
"""
|
||||
code, out = _git("diff", "--name-only", base, "--", *version_relevant_paths())
|
||||
if code != 0:
|
||||
return None, []
|
||||
paths = [p for p in out.splitlines() if p.strip()]
|
||||
if not paths:
|
||||
return False, []
|
||||
|
||||
rel_manifest = MANIFEST.relative_to(ROOT).as_posix()
|
||||
if paths == [rel_manifest]:
|
||||
return manifest_differs_beyond_version(
|
||||
manifest_text(), manifest_text(base)
|
||||
), paths
|
||||
return True, paths
|
||||
|
||||
|
||||
def check_version_is_minted(base: str = "origin/main") -> None:
|
||||
"""THE control (#3127 checklist 4), replacing "somebody remembers".
|
||||
|
||||
The checklist asks, of any hand-set component: *say what happens the
|
||||
release somebody forgets it.* This is the answer — the lane goes red,
|
||||
deterministically, because CI can compute whether the value should have
|
||||
moved. Its predecessor could only ask "did the number move at all", which
|
||||
any bump satisfied and which therefore proved nothing.
|
||||
|
||||
Four verdicts:
|
||||
|
||||
content changed, version did not FAIL — this is #2209, exactly
|
||||
version not in canonical shape FAIL — see below
|
||||
version implausibly in the future FAIL — a bad clock or a hand-edit
|
||||
version moved, content did not pass, and say so
|
||||
|
||||
THE LAST ROW IS NOT A FAILURE, DELIBERATELY. A needless re-mint costs one
|
||||
cache refresh and nothing else. Failing the lane over a harmless act is how
|
||||
a check earns a `--no-version` in somebody's muscle memory and stops
|
||||
running at all — which is the failure mode this whole file exists to
|
||||
prevent. The implication that matters is one-directional: content changed
|
||||
IMPLIES version moved.
|
||||
|
||||
A malformed version is worth failing on even though the installer would
|
||||
accept it. `K4` returns the manifest string verbatim, and `H == "unknown"`
|
||||
sets `forceOverwrite`, so a broken value either sorts as a normal string
|
||||
or reinstalls the plugin every single session (#3325). Neither is loud.
|
||||
|
||||
Stated against the BASE BRANCH rather than the last commit, as its
|
||||
predecessor was: a per-commit rule would demand a fresh mint from every
|
||||
commit in a batch, when what matters is that whatever reaches an install
|
||||
differs from what is cached. One mint per batch, which is also how a person
|
||||
would do it.
|
||||
"""
|
||||
code, _ = _git("rev-parse", "--verify", base)
|
||||
if code != 0:
|
||||
# Do NOT pass silently — a check that quietly no-ops is how this class
|
||||
# of bug survives in the first place.
|
||||
fail(
|
||||
f"cannot resolve {base}, so the version-bump check could not run. "
|
||||
f"cannot resolve {base}, so the minted-version check could not run. "
|
||||
f"Fetch it first — `git fetch --depth=1 origin main:refs/remotes/"
|
||||
f"origin/main` is enough, since this diffs two trees and needs no "
|
||||
f"common ancestor — or pass --no-version deliberately."
|
||||
)
|
||||
return
|
||||
|
||||
code, changed = _git("diff", "--name-only", base, "--", *SHIPPED)
|
||||
if code != 0:
|
||||
fail(f"git diff against {base} failed: {changed}")
|
||||
return
|
||||
if not changed.strip():
|
||||
ok(f"no shipped plugin changes against {base} — version bump not required")
|
||||
return
|
||||
|
||||
here, there = manifest_version(), manifest_version(base)
|
||||
here = manifest_version()
|
||||
if here is None:
|
||||
fail(f"could not read a version from {MANIFEST.relative_to(ROOT)}")
|
||||
return
|
||||
if not VERSION_RE.match(here):
|
||||
fail(
|
||||
f"the manifest version is {here!r}, which is not YYYY.MM.DD.HHMM.\n"
|
||||
f" One shape for every version in the family (#3127 checklist "
|
||||
f"10), zero-padded so the midnight case renders 2026.01.05.0000.\n"
|
||||
f" Run `make mint-plugin`."
|
||||
)
|
||||
return
|
||||
|
||||
minted = datetime.strptime(here, "%Y.%m.%d.%H%M").replace(tzinfo=timezone.utc)
|
||||
# A day of slack: the mint happens on a workstation and the lane runs
|
||||
# later, so a *small* skew is ordinary. A value further out than that is
|
||||
# a wrong clock or a typed year, and it makes the version lie about when
|
||||
# it was minted.
|
||||
if minted > datetime.now(timezone.utc) + timedelta(days=1):
|
||||
fail(
|
||||
f"the manifest version {here} is in the future. Either the clock "
|
||||
f"that minted it is wrong, or it was typed by hand."
|
||||
)
|
||||
return
|
||||
|
||||
changed, paths = shipped_content_changed(base)
|
||||
if changed is None:
|
||||
fail(f"git diff against {base} failed, so the version check could not run")
|
||||
return
|
||||
|
||||
there = manifest_version(base)
|
||||
if there is None:
|
||||
ok(f"no manifest on {base} — treating as a new plugin (version {here})")
|
||||
return
|
||||
if here == there:
|
||||
files = "\n ".join(changed.splitlines())
|
||||
|
||||
if changed and here == there:
|
||||
files = "\n ".join(paths)
|
||||
fail(
|
||||
f"plugin content changed but the manifest version is still {here}.\n"
|
||||
f" The installer compares versions to decide whether to refresh "
|
||||
f"its cache, so an unchanged version means these edits reach the repo "
|
||||
f"and stop there — the marketplace clone updates, the cache that "
|
||||
f"actually executes does not (issue #2209).\n"
|
||||
f" Bump `version` in {MANIFEST.relative_to(ROOT)}.\n"
|
||||
f"plugin content changed but the version is still {here}.\n"
|
||||
f" The installer decides whether to refresh its cache by "
|
||||
f"comparing this string, so an unchanged version means these edits "
|
||||
f"reach the repo and stop there — the marketplace clone updates, the "
|
||||
f"cache that actually executes does not (#2209, #1040, #2220).\n"
|
||||
f" Run `make mint-plugin`.\n"
|
||||
f" Changed:\n {files}"
|
||||
)
|
||||
elif changed:
|
||||
ok(f"plugin content changed and the version was minted {there} -> {here}")
|
||||
elif here != there:
|
||||
# Not a failure — see the docstring. Named rather than silent, because
|
||||
# the uninteresting cause (minted twice) and the interesting one (the
|
||||
# version-relevant set is too narrow to see what actually changed)
|
||||
# produce the same line, and only a person can tell them apart.
|
||||
ok(
|
||||
f"the version moved {there} -> {here} with no version-relevant "
|
||||
f"change — harmless, unless something DID change that the set "
|
||||
f"cannot see"
|
||||
)
|
||||
else:
|
||||
ok(f"plugin content changed and version moved {there} -> {here}")
|
||||
ok(f"nothing version-relevant changed against {base} — no mint required")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--no-version", action="store_true",
|
||||
help="skip the manifest version-bump check")
|
||||
help="skip the minted-version check; for `main`, where "
|
||||
"it would be measured against itself")
|
||||
parser.add_argument("--base", default="origin/main",
|
||||
help="branch the version bump is measured against")
|
||||
help="branch the version is measured against")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not HOOKS_DIR.is_dir():
|
||||
@@ -478,7 +711,7 @@ def main() -> int:
|
||||
check_local_prior_art_needs_no_instance()
|
||||
check_session_context_reports_its_version()
|
||||
if not args.no_version:
|
||||
check_version_bump(args.base)
|
||||
check_version_is_minted(args.base)
|
||||
|
||||
print()
|
||||
if failures:
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mint the plugin's version — `YYYY.MM.DD.HHMM`, UTC, zero-padded.
|
||||
|
||||
Run this whenever you change something under `plugin/` or `.claude-plugin/`,
|
||||
before you commit:
|
||||
|
||||
make mint-plugin # or: python3 scripts/mint_plugin_version.py
|
||||
|
||||
WHY A SCRIPT AND NOT A BUILD STEP. `plugin/` is not in the Docker image.
|
||||
Installs fetch it straight from this git repo via `.claude-plugin/
|
||||
marketplace.json`, so **a push IS the release** — there is no build between
|
||||
you committing and a user fetching, and therefore no moment at which CI could
|
||||
stamp a version in. Every other artifact in the family derives its version
|
||||
during a build (note #3127 §2). This one has no build to derive during.
|
||||
|
||||
WHICH CLOCK, AND WHY IT DIFFERS FROM THE SERVER IMAGE — the divergence is
|
||||
deliberate, and it lives one directory away from its opposite, so it is
|
||||
exactly what a later "let's make these consistent" change would collapse:
|
||||
|
||||
server image name from COMMIT time, ordering key from BUILD time
|
||||
(two lanes building one source must report one string;
|
||||
a rebuild of an older commit must not go backwards)
|
||||
plugin one value, from MINT time
|
||||
|
||||
§2's reason for commit time is that two lanes build one source. The plugin has
|
||||
one lane and no build, so that reason does not reach it and paying its cost
|
||||
buys nothing. What is given up is reproducibility-from-history: you cannot
|
||||
recompute this value later, only verify that it moved when it had to.
|
||||
|
||||
That trade is acceptable ONLY because of what #3325 established by reading the
|
||||
installer's code: the refresh test is `P.version === H`, plain string
|
||||
equality, with no ordering comparison anywhere. Where a comparator ORDERS, an
|
||||
unreproducible version is dangerous — nothing can check it is right. Where it
|
||||
only tests equality, "did it change when it should have" is the entire
|
||||
specification, and `check_plugin.py` checks that completely.
|
||||
|
||||
The manifest is rewritten with a surgical replacement of the `version` line
|
||||
rather than `json.dump`, because its formatting and key order are not this
|
||||
script's to decide and a whole-file reformat would make every mint an
|
||||
unreadable diff.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MANIFEST = ROOT / "plugin" / ".claude-plugin" / "plugin.json"
|
||||
|
||||
# Four dot-separated numeric fields, zero-padded, and nothing else — one shape
|
||||
# for every human-readable version in the family (#3127 checklist 10). The
|
||||
# padding is load-bearing for the midnight case the checklist names by hand:
|
||||
# 2026.01.05.0000, which an unpadded `%-H%M` would render as `0` and silently
|
||||
# shorten. Harmless while nothing orders these, wrong the moment anything does.
|
||||
VERSION_RE = re.compile(r"^\d{4}\.\d{2}\.\d{2}\.\d{4}$")
|
||||
VERSION_FORMAT = "%Y.%m.%d.%H%M"
|
||||
|
||||
# The `version` line, captured so its surroundings survive byte-for-byte.
|
||||
VERSION_LINE_RE = re.compile(r'^(\s*"version"\s*:\s*")([^"]*)(".*)$', re.M)
|
||||
|
||||
|
||||
def mint(now: datetime | None = None) -> str:
|
||||
"""The version for this moment. UTC, always.
|
||||
|
||||
The conversion is not decoration: `strftime` renders whatever offset the
|
||||
datetime carries, so without it two people minting the same instant in
|
||||
different zones produce different strings — and the string IS the
|
||||
artifact's identity. A naive datetime is read as UTC rather than as the
|
||||
machine's zone, because that is this function's stated contract and
|
||||
guessing the host's offset is how the bug comes back by another route.
|
||||
"""
|
||||
moment = now or datetime.now(timezone.utc)
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
return moment.astimezone(timezone.utc).strftime(VERSION_FORMAT)
|
||||
|
||||
|
||||
def rewrite(text: str, version: str) -> str:
|
||||
"""`text` with its `version` value replaced, and everything else untouched.
|
||||
|
||||
Raises rather than falling back to a JSON round-trip: a manifest this
|
||||
cannot match is one whose shape changed, and quietly reformatting the file
|
||||
to cope would be a much larger edit than the caller asked for.
|
||||
"""
|
||||
# Counted BEFORE substituting, not via subn's return: a capped `subn`
|
||||
# reports the replacements it made, so a manifest with two `version` lines
|
||||
# would look like a clean single match while the second one — the real one,
|
||||
# perhaps — kept its old value.
|
||||
matches = VERSION_LINE_RE.findall(text)
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
f"expected exactly one `version` line in the manifest, found {len(matches)}"
|
||||
)
|
||||
return VERSION_LINE_RE.sub(
|
||||
lambda m: f"{m.group(1)}{version}{m.group(3)}", text, count=1
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Mint the plugin's version.")
|
||||
parser.add_argument(
|
||||
"--check", action="store_true",
|
||||
help="print the version that WOULD be minted and change nothing",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
version = mint()
|
||||
if args.check:
|
||||
print(version)
|
||||
return 0
|
||||
|
||||
try:
|
||||
text = MANIFEST.read_text()
|
||||
except OSError as exc:
|
||||
print(f"cannot read {MANIFEST.relative_to(ROOT)}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
previous = json.loads(text).get("version")
|
||||
except Exception:
|
||||
previous = None
|
||||
|
||||
if previous == version:
|
||||
# Same minute. Not an error — the value is already correct for now, and
|
||||
# failing here would turn "I ran it twice" into a problem to solve.
|
||||
print(f"plugin version already {version} (same minute) — unchanged")
|
||||
return 0
|
||||
|
||||
try:
|
||||
MANIFEST.write_text(rewrite(text, version))
|
||||
except ValueError as exc:
|
||||
print(f"{MANIFEST.relative_to(ROOT)}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"plugin version {previous} -> {version}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Claude Code PreToolUse hook for Bash.
|
||||
# Reads the tool input JSON from stdin; if the command is a git commit
|
||||
# and fable-mcp files (other than pyproject.toml) are staged, bumps
|
||||
# the fable-mcp patch version before the commit proceeds.
|
||||
#
|
||||
# Exits 0 always so it never blocks the commit.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
input=$(cat)
|
||||
command=$(echo "$input" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
# Claude Code sends {tool_input: {command: ...}}
|
||||
ti = data.get('tool_input', data)
|
||||
print(ti.get('command', ''))
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
# Only act on git commit commands
|
||||
if ! echo "$command" | grep -qE "git commit"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Check if fable-mcp files other than pyproject.toml are staged
|
||||
fable_staged=$(git diff --cached --name-only 2>/dev/null \
|
||||
| grep "^fable-mcp/" \
|
||||
| grep -v "^fable-mcp/pyproject.toml$" \
|
||||
|| true)
|
||||
|
||||
if [ -n "$fable_staged" ]; then
|
||||
bash "$REPO_ROOT/scripts/bump_fable_mcp_version.sh"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -45,6 +45,31 @@ from quart import Quart
|
||||
# The accepted cost: an agent that never opens create_note's docstring never
|
||||
# learns the field exists. Guidance lives in the create_note / update_note
|
||||
# docstrings and the using-scribe skill instead.
|
||||
#
|
||||
# Milestone 333 step 3 (2026-09-04) bought the HOW bullet's second clause —
|
||||
# search(content_type="rule") before a consequential act — by TRADING OUT
|
||||
# "Processes are saved procedures (follow verbatim)" and "Deletes are
|
||||
# trash-recoverable". Recorded so the trade is not silently reversed:
|
||||
# - Both were already in test_instruction_surfaces_agree's DISPLACED_TOPICS
|
||||
# and already stated on a delivered surface, so nothing fell off: the
|
||||
# process reflex is in every scribe-proc-* skill listing (each says the
|
||||
# process governs and is followed verbatim), and trash recovery is in the
|
||||
# delete_*/list_trash/restore docstrings, which is where per-tool guidance
|
||||
# belongs by this block's own doctrine.
|
||||
# - What it bought is not per-tool guidance and has nowhere else to live at
|
||||
# session-start altitude. Rules were retrievable only by RESIDENCY: the
|
||||
# always-on preload put them in front of the agent, and nothing told a
|
||||
# session to go looking for one it had not been handed. The tier split is
|
||||
# therefore load-bearing on ANY install (rule 115): a delivered rule costs
|
||||
# tokens in every session forever, so a rulebook that only delivers cannot
|
||||
# grow past what one session can hold, and every rule worth keeping has to
|
||||
# become resident to bind at all. Retrieval is what lets it keep growing —
|
||||
# and retrieval fires only if something asks, which nothing told a session
|
||||
# to do. A tool-choice reflex asks least of all (#3476, #161).
|
||||
# - This states the PULL for conditional rules, exactly as the surrounding
|
||||
# line states it for always-on ones. Rule 119 makes these surfaces the
|
||||
# specification, so the same sentence lands on all three session-start
|
||||
# surfaces, and test_instruction_surfaces_agree pins it.
|
||||
_INSTRUCTIONS = """
|
||||
Scribe is the operator's self-hosted second brain and system of record — and
|
||||
yours: recall from it before acting, record as you go. Keep no parallel copy
|
||||
@@ -63,13 +88,13 @@ Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
|
||||
active project_id to stay in scope.
|
||||
- WHERE work happens: Systems. Tag records with system_ids as you write;
|
||||
create_system when the area is unmodelled.
|
||||
- HOW: rules are binding — list_always_on_rules() at session start.
|
||||
- HOW: rules bind. list_always_on_rules() at start; before a consequential
|
||||
act, search(content_type="rule") — the resident set is not all of them.
|
||||
- UI: the project's design system is binding — resolve_design_system /
|
||||
get_design_system_stylesheet before hand-writing a value.
|
||||
- REUSE: search snippets before writing a helper; record what you build with
|
||||
create_snippet; classify shapes against canon (classify_shapes) — a
|
||||
consumer map is rows, never prose. Processes are saved procedures (follow
|
||||
verbatim). Deletes are trash-recoverable.
|
||||
consumer map is rows, never prose.
|
||||
|
||||
A task is a note with status (*_note vs *_task tools).
|
||||
Creates are duplicate-gated: a near-match BLOCKS and returns the existing
|
||||
|
||||
@@ -57,7 +57,7 @@ async def get_milestone(milestone_id: int) -> dict:
|
||||
return {
|
||||
"milestone": out,
|
||||
"steps": [t.to_dict() for t in steps],
|
||||
**rulebooks_svc.rules_payload(applicable),
|
||||
**rulebooks_svc.rules_payload(applicable, user_id=uid, source="get_milestone"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ async def enter_project(project_id: int) -> dict:
|
||||
],
|
||||
"design_system": design_system,
|
||||
"milestone_summary": milestone_summary,
|
||||
**rulebooks_svc.rules_payload(applicable),
|
||||
**rulebooks_svc.rules_payload(applicable, user_id=uid, source="enter_project"),
|
||||
"open_tasks": [
|
||||
{
|
||||
"id": t.id, "title": t.title, "status": t.status,
|
||||
@@ -251,7 +251,7 @@ async def get_project(project_id: int) -> dict:
|
||||
applicable = await rulebooks_svc.get_applicable_rules(
|
||||
project_id=project_id, user_id=uid,
|
||||
)
|
||||
data.update(rulebooks_svc.rules_payload(applicable))
|
||||
data.update(rulebooks_svc.rules_payload(applicable, user_id=uid, source="get_project"))
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from scribe.mcp._context import current_user_id
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import trash as trash_svc
|
||||
from scribe.services.rule_usage import record_rule_pulled, record_rule_surfaced
|
||||
|
||||
|
||||
# ── Rulebook CRUD ───────────────────────────────────────────────────────
|
||||
@@ -264,6 +265,15 @@ async def list_always_on_rules(project_id: int = 0) -> dict:
|
||||
"""
|
||||
uid = current_user_id()
|
||||
rules = await rulebooks_svc.list_always_on_rules(uid, project_id=project_id)
|
||||
# AMBIENT source: the resident set, handed over whole. No ranker chose
|
||||
# these, so they must not land in the pull-through numerator's denominator
|
||||
# — but they must land SOMEWHERE, or the largest rule surface in the
|
||||
# product stays the one surface its own scoreboard cannot see (#3473).
|
||||
record_rule_surfaced(
|
||||
user_id=uid,
|
||||
rule_ids=[r.id for r in rules],
|
||||
source="list_always_on_rules",
|
||||
)
|
||||
return {
|
||||
"rules": [_rule_summary(r) for r in rules],
|
||||
"total": len(rules),
|
||||
@@ -288,6 +298,11 @@ async def get_rule(rule_id: int) -> dict:
|
||||
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
||||
if rule is None:
|
||||
raise ValueError(f"rule {rule_id} not found")
|
||||
# THE pull that matters. The write-path rule arm's own message ends "Read
|
||||
# it with get_rule(N)", so this is the exact action the hint asks for and
|
||||
# the only evidence that one landed. Recorded after the access check, so a
|
||||
# refused read is not counted as a pull.
|
||||
record_rule_pulled(user_id=uid, rule_id=int(rule.id), source="mcp_get_rule")
|
||||
return await rulebooks_svc.rule_detail(uid, rule)
|
||||
|
||||
|
||||
@@ -300,6 +315,61 @@ async def create_rule(
|
||||
) -> dict:
|
||||
"""Create a new rule in a rulebook (a SHARED rule — keep it general).
|
||||
|
||||
PROPOSE RULES READILY, AND WRITE ONE WHEN THE OPERATOR SAYS YES. Noticing
|
||||
that something has hardened into a standing instruction is valuable work,
|
||||
and a session that notices it and says nothing has thrown the observation
|
||||
away. So raise it whenever you see one. The single step that belongs
|
||||
between noticing and writing is the operator's yes: a rule binds every
|
||||
future session, and they are the person it binds.
|
||||
|
||||
Their yes is also the only moment the rule is reliably IN FRONT of them.
|
||||
After the write it may not be again for months — a conditional rule is not
|
||||
read aloud at session start, and a project-scoped one does not appear in
|
||||
an unfiltered list_rules() at all. So the proposal is the review.
|
||||
|
||||
When the operator asks for a rule in so many words, that IS the yes —
|
||||
write it and move on. The loop below is for the rule you thought of.
|
||||
|
||||
A PROPOSAL CARRIES FOUR THINGS, and the fourth is the one that decides it:
|
||||
|
||||
1. WHAT it would require — the statement, in the words it would carry,
|
||||
not a gloss of them. The operator is agreeing to text.
|
||||
2. INTENT — what it changes about how work gets done, and what goes
|
||||
wrong today without it. "Be careful about X" is not an intent; the
|
||||
behaviour that would differ tomorrow is.
|
||||
3. WHY NOW — the incident, observation or decision behind it. Pass that
|
||||
record as arose_from_id, and say it in the conversation too: the
|
||||
field is for the reader six months out, the sentence is for the
|
||||
person deciding.
|
||||
4. HOW IT WOULD BE ENFORCED — a test, a CI check, a hook, a schema
|
||||
constraint, a duplicate gate, a review step... or nothing, in which
|
||||
case say so plainly: "nothing — this is prose a session has to
|
||||
remember." Answer this one honestly and it will sometimes dissolve
|
||||
the rule, which is the point rather than a side effect. What a test
|
||||
can assert should BE that test; a rule is what remains when nothing
|
||||
mechanical can hold the thing. A rulebook grows by default and
|
||||
shrinks only on purpose, so a question that prevents a rule is worth
|
||||
more than any question that improves one's wording.
|
||||
|
||||
THEN CLOSE WITH A QUESTION THEY CAN ANSWER IN ONE WORD. Offer three
|
||||
answers, and make the middle one the easy one:
|
||||
|
||||
* "Approve it AS WRITTEN" — you create it with the statement exactly as
|
||||
shown. This is what makes element 1 load-bearing: they approved TEXT,
|
||||
so that text is what gets stored, verbatim.
|
||||
* "LET'S TALK ABOUT IT" — the wording, the scope, the tier, whether it
|
||||
wants to be a rule at all. Most good rules arrive this way, so treat
|
||||
this answer as the expected one rather than a setback.
|
||||
* "NO" — let it go. If the observation is still worth keeping, it is a
|
||||
note (create_note): recorded, findable, and binding on nobody.
|
||||
|
||||
Where the interface offers structured choices, ask it that way — a
|
||||
question with named options is answered in a click, while the same
|
||||
question inside a paragraph is answered by scrolling past. Where it does
|
||||
not, write the three options out as three options. Either way ask once
|
||||
and let the answer stand; re-raising a declined proposal argues a rule
|
||||
into existence, which is the thing this whole loop exists to prevent.
|
||||
|
||||
A rulebook rule is shared by every project that gets the rulebook: an
|
||||
always_on rulebook binds ALL your projects; a subscribed rulebook binds the
|
||||
projects that opt in. So a rulebook rule must read as a general standard —
|
||||
@@ -418,6 +488,16 @@ async def create_project_rule(
|
||||
the rule is returned in get_project's applicable_rules (under
|
||||
project_rules) and in list_rules(project_id=...).
|
||||
|
||||
PROPOSE, THEN WRITE ON A YES — create_rule's opening carries the whole
|
||||
loop: the four things a proposal states (what it would require, its
|
||||
intent, why now, and how it would be enforced) and the one-word question
|
||||
that closes it (approve as written / talk about it / no). All of it
|
||||
applies here unchanged. Reach for that loop MORE readily on this surface,
|
||||
not less: a project rule stays out of an unfiltered list_rules(), and a
|
||||
conditional one stays out of session start too, so the operator's yes is
|
||||
the one moment this rule is certain to have been seen by the person it
|
||||
binds.
|
||||
|
||||
Check first whether a rule is the right shape at all — create_rule's
|
||||
opening asks that question and it applies identically here. A visual
|
||||
standard is a design system; a procedure is a process (create_process);
|
||||
|
||||
@@ -112,6 +112,7 @@ async def search(
|
||||
return await _search_rules(uid, q, limit)
|
||||
is_task = {"note": False, "task": True}.get(content_type) # None => any
|
||||
t0 = time.perf_counter()
|
||||
report: dict = {}
|
||||
raw = await semantic_search_notes(
|
||||
uid, q, limit=limit, is_task=is_task,
|
||||
project_id=project_id or None,
|
||||
@@ -119,12 +120,14 @@ async def search(
|
||||
# An explicit search reaches everything the operator may read, including
|
||||
# records shared with them one-to-one.
|
||||
scope="read",
|
||||
report=report,
|
||||
)
|
||||
record_retrieval(
|
||||
user_id=uid, source="mcp_search", query=q,
|
||||
threshold=DEFAULT_SIMILARITY_THRESHOLD, limit=limit,
|
||||
project_id=project_id or None, is_task=is_task, results=raw,
|
||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||
best_available=report.get("best_available_score"),
|
||||
)
|
||||
owners = await owner_names_for(
|
||||
{int(note.user_id) for _s, note in raw if note.user_id != uid}
|
||||
@@ -158,17 +161,54 @@ async def retrieval_telemetry(days: int = 30) -> dict:
|
||||
hand-probing the live instance, which is how the last such decision had to
|
||||
be made.
|
||||
|
||||
Two readouts, from the two tables built for them:
|
||||
Three readouts, from the three tables built for them:
|
||||
|
||||
`sources` — per retrieval surface (`auto_inject`, `write_path`,
|
||||
`mcp_search`, …), from `retrieval_logs`: `calls`, `zero_result_calls`,
|
||||
`cleared_threshold` (how often the best hit beat the threshold in force for
|
||||
that call), the `top_score` spread (p10/p50/p90/min/max), `avg_result_count`
|
||||
and `p90_duration_ms`. THE number to read first is `cleared_threshold`
|
||||
against `calls`, with the spread beside it: a surface that clears its bar
|
||||
on nearly every call is either well-tuned or too loose, and p10 says which.
|
||||
`near_misses`, the `top_score` spread (p10/p50/p90/min/max),
|
||||
`avg_result_count` and `p90_duration_ms`.
|
||||
|
||||
`usage` — from `note_usage_events`, at the per-note grain
|
||||
THE NUMBER TO READ FIRST IS `near_misses.p90`, AGAINST THE THRESHOLD IN
|
||||
FORCE FOR THAT SURFACE. It is measured on the calls the BAR turned away —
|
||||
zero-result calls, minus the ones whose zero was a repeat the reader had
|
||||
already been shown — using the best score the ranker reached before the bar
|
||||
rejected it. So it is the one figure here that says something the bar
|
||||
cannot make true by construction, and `max` is always below the threshold:
|
||||
an above-bar candidate nobody excluded would have been returned. A bar at 0.72 turning away a stream of 0.71s is set too
|
||||
high by a hair and the surface is losing hits it should have had. The same
|
||||
bar turning away 0.30s is working, and the corpus simply had nothing. Both
|
||||
render as a zero-result call, and nothing else in this readout tells them
|
||||
apart.
|
||||
|
||||
`near_misses` is `null` when no declining call in the window measured it —
|
||||
rows written before #3670 shipped cannot know. That is "not measured", not
|
||||
"nothing came close"; a 0.0 there would be a claim about the corpus
|
||||
invented out of a caller's silence.
|
||||
|
||||
THERE IS NO `cleared_threshold` ANY MORE, and if you remember one, that
|
||||
memory is of a tautology (#3670). The search applies the bar before
|
||||
returning, so every returned result cleared it by construction and a call
|
||||
with no results has no score to compare: the field was true exactly when
|
||||
`result_count > 0`, i.e. it was `calls - zero_result_calls` under a name
|
||||
that promised a second opinion. `zero_result_calls + cleared_threshold ==
|
||||
calls` held on all nineteen readings ever taken. The reading procedure
|
||||
built on it — "clears its bar on nearly every call" — asked you to compare
|
||||
a number with itself.
|
||||
|
||||
CHECK `suppression` BEFORE CONCLUDING ANYTHING FROM `zero_result_calls`. A
|
||||
zero-result call is two different events wearing one number: the ranker
|
||||
found nothing above the bar, or it found only what this session had already
|
||||
been shown. Just the first is evidence about the bar. `suppression` splits
|
||||
them where the surface can tell — `zero_because_already_shown` comes off
|
||||
`zero_result_calls` to leave the true ranker declines.
|
||||
|
||||
`suppression` is `null` when NO row in the window reported it, and that is
|
||||
"not measured here", NOT "none suppressed". Surfaces that pass their
|
||||
exclusions into the search never see what was dropped, so they cannot say.
|
||||
Do not read a null as a zero: reading an artifact as a measurement is how
|
||||
this surface got mis-scoped once already (#3311, #3497).
|
||||
|
||||
`usage` — NOTES ONLY, from `note_usage_events`, at the per-note grain
|
||||
`retrieval_logs` cannot be indexed at: `surfaced` (ranked surfacings — a
|
||||
scored surface CHOSE the record), `ambient` (the rest), `pulled` split into
|
||||
`pulled_by_agent` / `pulled_by_human`, the distinct-note counts, and
|
||||
@@ -182,6 +222,84 @@ async def retrieval_telemetry(days: int = 30) -> dict:
|
||||
tuned against — only by a pull the agent made. Aggregating across the
|
||||
mcp_/rest_ prefix would silently answer the wrong one.
|
||||
|
||||
`usage["by_source"]` — THE number to tune a threshold against, because the
|
||||
top-level `pull_through` is a corpus average and averages the surfaces
|
||||
together. Per surface: `notes_surfaced`, `notes_pulled`, `pull_through`,
|
||||
and `ambient: true` on surfaces whose surfacings were not scored choices
|
||||
(their ratio is null — "surfaced often, opened never" is not a judgment
|
||||
about a record nothing chose). Read it as: of the distinct notes THIS
|
||||
surface put in front of the agent, how many did the agent then open?
|
||||
|
||||
It is an UPPER BOUND per surface: a pull records the door it came
|
||||
through, not the surface that led there, so a note surfaced by two surfaces
|
||||
and opened once counts for both — attribution would need the session
|
||||
identity #2085 declined to invent. `by_source_failed: true` means that one
|
||||
query failed while the rest of the readout stood.
|
||||
|
||||
`rule_usage` — the same question for RULES, from `rule_usage_events`:
|
||||
`surfaced` and `ambient`, `pulled` split into `pulled_by_agent` /
|
||||
`pulled_by_human`, the distinct-rule counts, and `pull_through` on the same
|
||||
definition (agent pulls over RANKED surfacings).
|
||||
|
||||
A SEPARATE BLOCK, not folded into `usage`, and reading it as one number
|
||||
with that is the mistake to avoid. The corpora differ by orders of
|
||||
magnitude — a few dozen eligible rules against thousands of notes — so a
|
||||
blended ratio would be the note ratio with noise on it and would hide the
|
||||
rule arm entirely.
|
||||
|
||||
`surfaced` VS `ambient` IS THE READING THAT MATTERS HERE. `surfaced` counts
|
||||
rules a ranker chose — today only the write-path arm — and those are claims
|
||||
a pull can settle. `ambient` counts BULK DELIVERIES: the SessionStart
|
||||
preload, `list_always_on_rules`, and the `rules_payload` surfaces
|
||||
(`enter_project`, `get_project`, `get_milestone`, `start_planning`,
|
||||
`get_task`), which hand over the whole applicable set at once with nobody
|
||||
choosing anything. A large `ambient` says the resident set is big and
|
||||
arrives often — never that it is useful, and never that it is read.
|
||||
|
||||
`pull_through` therefore divides by `surfaced` alone. Fold the preload in
|
||||
and growing the always-on set would depress the arm's measured precision
|
||||
while trimming it would flatter it, for reasons having nothing to do with
|
||||
the arm. To judge the PRELOAD instead, compare `ambient` against pulls of
|
||||
those same rules over time: a resident set surfaced thousands of times and
|
||||
opened never is the dead-weight signal, one tier up.
|
||||
|
||||
Read it against `sources["write_path_rule"]`. That arm was once believed
|
||||
never to decline — the reading that scoped #3311 — but it was the arm's
|
||||
`retrieval_logs` row being written only on calls that FOUND something, so
|
||||
the zeros were missing rather than absent (#3497). Measured since, it
|
||||
declines the large majority of its calls like any other surface.
|
||||
|
||||
EVERY COUNTER BLOCK CARRIES ITS OWN COVERAGE — `complete_from` and
|
||||
`covers_window`. `complete_from` is when the number became trustworthy:
|
||||
for one source, its first recorded row; for a section that sums several,
|
||||
the LATEST of theirs, because a total is complete only once every
|
||||
contributor was being written. `covers_window: false` means the window
|
||||
reaches back further than the recording does, so the count is a fraction
|
||||
of the period it appears to describe.
|
||||
|
||||
READ IT BEFORE COMPARING TWO NUMBERS, and especially before comparing
|
||||
across a deploy. A counter added last week, read over a 30-day window,
|
||||
reports a real count against an imagined denominator — and the result is
|
||||
a plausible fraction rather than an obvious zero, which is what makes it
|
||||
dangerous. That reading cost milestone #379 five steps aimed at a defect
|
||||
that did not exist.
|
||||
|
||||
`covers_window` is null, never false, when nothing was ever recorded:
|
||||
"no measurement" is not "partial measurement", the same distinction
|
||||
`suppression`'s null carries a few paragraphs up.
|
||||
|
||||
A SOURCE SHOWING `calls: 0` WAS RECORDING AND MADE NO CALLS. `sources`
|
||||
lists every source the table has ever held, not only those active in the
|
||||
window, so a surface that stopped firing stays visible rather than
|
||||
disappearing — being absent is reserved for a source that has never
|
||||
recorded at all. Its score fields are null, not zero: the calls are a
|
||||
real observation, the distribution is not one.
|
||||
|
||||
`rule_usage_failed: true` means that read failed while the rest of the
|
||||
readout stood. The counts are still present so a caller can render, but
|
||||
they are zeros meaning "could not find out", not "nothing happened" — do
|
||||
not report a pull-through from a block carrying that flag.
|
||||
|
||||
Scoped to your own telemetry — a retrieval log records what your agent
|
||||
asked for, query text included, and is not a shared record kind.
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ async def get_task(task_id: int) -> dict:
|
||||
applicable = await rulebooks_svc.get_applicable_rules(
|
||||
project_id=note.project_id, user_id=uid,
|
||||
)
|
||||
data.update(rulebooks_svc.rules_payload(applicable))
|
||||
data.update(rulebooks_svc.rules_payload(applicable, user_id=uid, source="get_task"))
|
||||
data.update(await access_svc.describe_provenance(uid, note))
|
||||
# Same reasoning as get_note's record_pulled, and this is the tool where it
|
||||
# matters MOST: auto-inject ranks kind-blind over a corpus that is
|
||||
|
||||
@@ -28,6 +28,7 @@ from scribe.models.invitation import InvitationToken # noqa: E402, F401
|
||||
from scribe.models.embedding import NoteEmbedding, RuleEmbedding # noqa: E402, F401
|
||||
from scribe.models.retrieval_log import RetrievalLog # noqa: E402, F401
|
||||
from scribe.models.note_usage import NoteUsageEvent # noqa: E402, F401
|
||||
from scribe.models.rule_usage import RuleUsageEvent # noqa: E402, F401
|
||||
from scribe.models.project import Project # noqa: E402, F401
|
||||
from scribe.models.milestone import Milestone # noqa: E402, F401
|
||||
from scribe.models.task_log import TaskLog # noqa: E402, F401
|
||||
|
||||
@@ -42,8 +42,26 @@ class RetrievalLog(Base):
|
||||
# False=notes, NULL=any.
|
||||
is_task: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
result_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# How many scored hits this call DROPPED because the session had already
|
||||
# been shown them. NULLABLE, and the null is load-bearing: it means "this
|
||||
# surface does not report suppression", which must not read as "nothing was
|
||||
# suppressed". `result_count == 0` alone conflates two different events —
|
||||
# the ranker found nothing above threshold, and the ranker found something
|
||||
# the reader already had — and only the first says a threshold is too high.
|
||||
# Reading a zero as a ranker decline is how #3311 mis-scoped a milestone;
|
||||
# an unmeasured value that renders as 0 is the same mistake with a nicer
|
||||
# face, so surfaces that filter INSIDE the search leave this null.
|
||||
suppressed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
top_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
min_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
# The best score the ranker COULD have offered, before the threshold — as
|
||||
# against `top_score`, which is the best it DID offer. They are equal on
|
||||
# any call that returned something, and only this one exists on a call
|
||||
# that returned nothing, which is the only place a bar can be judged from
|
||||
# (#3670). Null means the caller did not measure it, never "nothing was
|
||||
# close": a 0.0 there would read as a corpus with no relevant records at
|
||||
# all, which is an artifact standing in for a measurement.
|
||||
best_available_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
# [{"id": int, "score": float, "rank": int}, ...], highest-first.
|
||||
result_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
@@ -67,6 +85,7 @@ class RetrievalLog(Base):
|
||||
"project_id": self.project_id,
|
||||
"is_task": self.is_task,
|
||||
"result_count": self.result_count,
|
||||
"suppressed_count": self.suppressed_count,
|
||||
"top_score": self.top_score,
|
||||
"min_score": self.min_score,
|
||||
"result_ids": self.result_ids,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
from sqlalchemy import BigInteger, Index, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import CreatedAtMixin, iso
|
||||
|
||||
SURFACED = "surfaced"
|
||||
PULLED = "pulled"
|
||||
|
||||
|
||||
class RuleUsageEvent(Base, CreatedAtMixin):
|
||||
"""One row per time a rule was SURFACED to the agent, or PULLED in full.
|
||||
|
||||
The sibling `note_usage_events` has had since 2026-07, third in the line
|
||||
after `rule_embeddings` and `rule_versions` — and, like those, it exists
|
||||
because the rule side kept inheriting machinery built for notes and
|
||||
quietly getting the weaker version of it.
|
||||
|
||||
WHY RULES NEED THEIR OWN AND CANNOT SHARE THE NOTE TABLE. Not squeamishness
|
||||
about a polymorphic column — the row shares no note-specific fields and the
|
||||
aggregate readout is the same shape, which is the strongest case for
|
||||
sharing that note #3163 admits. What decides it is IDENTITY AT RESTORE. A
|
||||
note id and a rule id are different namespaces resolved through different
|
||||
maps, and `note_usage_events`'s importer maps `note_id` through
|
||||
`note_id_map` and drops what does not resolve. A rule id parked in that
|
||||
column would come back from a backup silently reattached to whatever note
|
||||
happened to take that number — telemetry that is not merely lost but wrong,
|
||||
and wrong in a way nothing downstream could detect.
|
||||
|
||||
WHAT THIS MEASURES, AND WHY IT DID NOT EXIST. The write-path standing-rule
|
||||
arm is the only retrieval surface in Scribe whose usefulness cannot be
|
||||
observed — and, not coincidentally, the only one that has never declined to
|
||||
fire (#3311: 296 calls, zero zero-result, 100% clearing its threshold).
|
||||
`retrieval_logs` gives it scores; scores say what the ranker thought, never
|
||||
whether the hint landed. Without a pull counter no install can tune the arm
|
||||
from evidence, only from the shape of a histogram.
|
||||
|
||||
Deliberately FK-FREE on `rule_id` and `user_id`, matching `note_usage_events`,
|
||||
`retrieval_logs` and `app_logs` — and diverging from `rule_versions`, which
|
||||
does carry FKs. The difference is what the row is FOR: a version is part of
|
||||
a rule's history and dies with it, while telemetry outlives the row it
|
||||
describes. Deleting a rule must not erase the evidence that it was surfaced
|
||||
forty times and opened never, because that evidence is precisely the case
|
||||
for having deleted it.
|
||||
|
||||
Cells left deliberately empty (note #3163's step 3): no share ACL — rules
|
||||
have none of their own; no soft delete — nothing restores a telemetry row,
|
||||
and the table is append-only; no embedding — an event is not a document.
|
||||
"""
|
||||
|
||||
__tablename__ = "rule_usage_events"
|
||||
|
||||
# BigInteger throughout, where the note twin uses Integer. `rule_id` has to
|
||||
# be, since `rules.id` is BigInteger — and once one column is, matching the
|
||||
# rest costs nothing and keeps the row uniform. A high-churn append-only
|
||||
# telemetry table is a poor place to discover an id ceiling.
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
rule_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
|
||||
# 'surfaced' | 'pulled'
|
||||
event: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
# Which surface produced it. A CONVENTION, not a fixed vocabulary, and the
|
||||
# note twin's comment explains why this one deliberately does not enumerate
|
||||
# its members: the previous such list went stale, naming a source nothing
|
||||
# wrote while omitting ones that existed, and a half-true enumeration reads
|
||||
# as authoritative in exactly the way that misleads (#2476).
|
||||
# `grep -rn record_rule_pulled\|record_rule_surfaced src/` is the
|
||||
# authoritative list, and unlike a comment it cannot drift.
|
||||
#
|
||||
# The mcp_/rest_ prefix split is load-bearing here for the same reason it is
|
||||
# for notes, and more so: "is this rule dead weight?" is served by any pull,
|
||||
# but "did that injected hint land?" — the question this arm exists to
|
||||
# answer — is served by AGENT pulls only. Never aggregate across the prefix
|
||||
# without saying why.
|
||||
source: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
# Every readout is "these rule ids, split by event" — a covering
|
||||
# composite beats separate single-column indexes for it.
|
||||
Index("ix_rule_usage_rule_event", "rule_id", "event"),
|
||||
Index("ix_rule_usage_created_at", "created_at"),
|
||||
Index("ix_rule_usage_user_id", "user_id"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"created_at": iso(self.created_at),
|
||||
"user_id": self.user_id,
|
||||
"rule_id": self.rule_id,
|
||||
"event": self.event,
|
||||
"source": self.source,
|
||||
}
|
||||
@@ -10,6 +10,61 @@ async def health():
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
def build_version_payload() -> dict:
|
||||
"""What build is this, separated into the values that answer different
|
||||
questions (rule 149).
|
||||
|
||||
UNTIL 2026-08-31 THIS RETURNED THE CHANNEL. `BUILD_VERSION` in CI was
|
||||
literally "dev" / "main" / the tag, so a running instance reported
|
||||
`{"version": "main"}` — a channel name sitting where a build identifier
|
||||
belongs. The cost was concrete: with a deploy misbehaving, nothing on the
|
||||
instance could say which commit was serving it, and the one endpoint whose
|
||||
job that is answered with the name of a branch.
|
||||
|
||||
The three values, and why they are three:
|
||||
|
||||
- `version` — the NAME, `YYYY.MM.DD.HHMM` from COMMIT time. Answers "is
|
||||
this the same code?", so two channels carrying one commit report the
|
||||
same string.
|
||||
- `build` — the ORDERING KEY, minutes since 2020-01-01 from BUILD time.
|
||||
Answers "may this be installed over that?". The ONLY value anything may
|
||||
compare; it is monotonic by construction, which neither a commit count
|
||||
(branches diverge) nor a commit time (rebuilds go backwards) is.
|
||||
- `channel` — its own field, never folded into the name.
|
||||
|
||||
Plus `commit`, so the artifact's claim about itself can be checked against
|
||||
the `:<sha>` it was published under (rule 145).
|
||||
|
||||
ABSENT RATHER THAN EMPTY when unknown. A local build has no ordering key
|
||||
and no channel, and saying so is honest; emitting `""` or a placeholder
|
||||
would let it claim a position in an update order it is not part of. A
|
||||
reader must treat a missing `build` as "cannot be ordered", not as zero.
|
||||
"""
|
||||
payload: dict = {"version": os.environ.get("APP_VERSION", "dev")}
|
||||
|
||||
# Reported verbatim, never validated against an enum — a build claiming
|
||||
# something unexpected is better shown than dropped (rule 149).
|
||||
for key, env in (("channel", "APP_CHANNEL"), ("commit", "APP_COMMIT")):
|
||||
value = (os.environ.get(env) or "").strip()
|
||||
if value:
|
||||
payload[key] = value
|
||||
|
||||
raw_key = (os.environ.get("APP_BUILD_KEY") or "").strip()
|
||||
if raw_key:
|
||||
try:
|
||||
# An INTEGER, not a string. A string ordering key is how a
|
||||
# comparison silently becomes lexicographic — "9" > "10" — which
|
||||
# is the same class of fault as folding the channel in: it reads
|
||||
# fine and orders wrong.
|
||||
payload["build"] = int(raw_key)
|
||||
except ValueError:
|
||||
# A malformed key is omitted rather than passed through: a reader
|
||||
# that cannot order is correct, one that orders on garbage is not.
|
||||
pass
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
@api.route("/version")
|
||||
async def version():
|
||||
return jsonify({"version": os.environ.get("APP_VERSION", "dev")})
|
||||
return jsonify(build_version_payload())
|
||||
|
||||
@@ -101,6 +101,46 @@ async def autoinject_retrieve():
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@plugin_bp.get("/tool-rules")
|
||||
@login_required
|
||||
async def pre_tool_rules():
|
||||
"""Standing rules for the plugin's PreToolUse hook on ACTIONS (#3476).
|
||||
|
||||
Answers "does a recorded rule speak to the command about to be run?" — the
|
||||
sibling of /prior-art, which can only answer that question about a code
|
||||
write. Rules about which tool to reach for (don't curl the forge, don't
|
||||
stand up a stack, don't run the suite locally) had no retrieval surface at
|
||||
all before this, which is why they all had to live in the resident preload.
|
||||
|
||||
Titles + trigger only, never the statement: the hint says a rule may apply
|
||||
and hands over `get_rule(id)`. One rule at most (RULEHINT_LIMIT), and empty
|
||||
most of the time.
|
||||
|
||||
Query:
|
||||
tool (str) — the tool about to run, e.g. `Bash`. Used in
|
||||
the hint's wording, not in the search: a
|
||||
rule is about the action, not the harness.
|
||||
command (str) — the command about to run; the semantic query.
|
||||
Absent or blank → empty, no search.
|
||||
repo (optional) — working repo remote, resolved to the bound
|
||||
project exactly as /retrieve and /prior-art.
|
||||
exclude_rule_ids (opt) — comma-separated rule ids already surfaced
|
||||
this session. SHARED with /prior-art's
|
||||
ledger on purpose: one session keeps one
|
||||
list, so a rule named by either arm is not
|
||||
re-offered by the other.
|
||||
"""
|
||||
tool = (request.args.get("tool") or "tool").strip()
|
||||
command = request.args.get("command") or ""
|
||||
project_id, _repo, _unbound = await _project_scope()
|
||||
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
|
||||
result = await plugin_ctx_svc.build_tool_rule_hint(
|
||||
g.user.id, tool, command,
|
||||
project_id=project_id, exclude_rule_ids=exclude_rule_ids,
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@plugin_bp.get("/prior-art")
|
||||
@login_required
|
||||
async def write_path_prior_art():
|
||||
|
||||
@@ -10,6 +10,9 @@ from quart import Blueprint, jsonify, request
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
import scribe.services.rulebooks as rulebooks_svc
|
||||
from scribe.services.trash import delete as trash_delete
|
||||
from scribe.services.rule_usage import (
|
||||
empty_rule_usage, record_rule_pulled, usage_for_rules,
|
||||
)
|
||||
|
||||
rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
|
||||
|
||||
@@ -136,13 +139,24 @@ async def list_rules():
|
||||
except ValueError:
|
||||
return jsonify({"error": "rulebook_id, topic_id, project_id must be integers"}), 400
|
||||
|
||||
uid = get_current_user_id()
|
||||
rows = await rulebooks_svc.list_rules(
|
||||
user_id=get_current_user_id(),
|
||||
user_id=uid,
|
||||
rulebook_id=rulebook_id,
|
||||
topic_id=topic_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
return jsonify({"rules": [r.to_dict() for r in rows]})
|
||||
items = [r.to_dict() for r in rows]
|
||||
# One aggregate for the whole page — a per-row lookup here would be N+1 by
|
||||
# construction, the same reason the snippet list does it this way. Every
|
||||
# row gets the key, zero-filled, so the UI renders "never surfaced" rather
|
||||
# than having to treat a missing field as a state. That matters more here
|
||||
# than for snippets: every rule on every install predates this table, so
|
||||
# for a while the zero-filled shape IS the common case.
|
||||
usage = await usage_for_rules([int(it["id"]) for it in items])
|
||||
for it in items:
|
||||
it["usage"] = usage.get(int(it["id"]), empty_rule_usage())
|
||||
return jsonify({"rules": items})
|
||||
|
||||
|
||||
@rulebooks_bp.post("/rulebook-topics/<int:topic_id>/rules")
|
||||
@@ -182,6 +196,11 @@ async def get_rule(rule_id: int):
|
||||
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
||||
if rule is None:
|
||||
return jsonify({"error": "rule not found"}), 404
|
||||
# `rest_` rather than `mcp_`, and the prefix is load-bearing: "is this rule
|
||||
# dead weight?" is served by any pull, but "did that injected hint land?"
|
||||
# — the question this arm exists to answer — is served by AGENT pulls only.
|
||||
# A person clicking through the rule list says nothing about the hint.
|
||||
record_rule_pulled(user_id=uid, rule_id=int(rule.id), source="rest_rule")
|
||||
return jsonify(await rulebooks_svc.rule_detail(uid, rule))
|
||||
|
||||
|
||||
|
||||
@@ -44,17 +44,20 @@ async def search_route():
|
||||
project_id = request.args.get("project_id", type=int)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
report: dict = {}
|
||||
results = await semantic_search_notes(
|
||||
uid, q, limit=limit, is_task=is_task, threshold=_REST_SEARCH_THRESHOLD,
|
||||
project_id=project_id, system_id=system_id,
|
||||
# The user typed this, so it reaches everything they may read.
|
||||
scope="read",
|
||||
report=report,
|
||||
)
|
||||
record_retrieval(
|
||||
user_id=uid, source="rest_search", query=q,
|
||||
threshold=_REST_SEARCH_THRESHOLD, limit=limit,
|
||||
project_id=project_id, is_task=is_task, results=results,
|
||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||
best_available=report.get("best_available_score"),
|
||||
)
|
||||
owners = await owner_names_for(
|
||||
{int(note.user_id) for _s, note in results if note.user_id != uid}
|
||||
|
||||
@@ -6,20 +6,63 @@ write that never errors and never lands (the #2663 GC footgun). This module is
|
||||
the one place that gets the pattern right: strong references in ``_pending``,
|
||||
discarded on completion, with failures logged at WARNING instead of vanishing.
|
||||
|
||||
``note_usage`` and ``retrieval_telemetry`` predate this module and carry their
|
||||
own copies with bespoke canary semantics; new fire-and-forget callers use this
|
||||
instead of writing a fourth copy.
|
||||
``retrieval_telemetry`` predates this module and keeps its own copy, because
|
||||
its canary is a genuinely different shape — one process-wide flag and no
|
||||
AppLog row. ``note_usage`` and ``rule_usage`` share ``report_telemetry_failure``
|
||||
below. New fire-and-forget callers use ``spawn`` rather than writing another
|
||||
copy of the strong-reference dance.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
from collections.abc import Coroutine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_pending: set[asyncio.Task] = set()
|
||||
|
||||
# Sites that have already dropped their once-per-process AppLog row, keyed
|
||||
# "<subsystem>:<site>". A readout can run on every list render — without this,
|
||||
# a broken table turns the error log into a firehose that buries the finding it
|
||||
# exists to surface.
|
||||
_reported: set[str] = set()
|
||||
|
||||
|
||||
async def report_telemetry_failure(subsystem: str, site: str) -> None:
|
||||
"""Make a swallowed telemetry failure visible. Call from an except block.
|
||||
|
||||
WARNING to the process log every time; one AppLog error row per process per
|
||||
(subsystem, site) so the admin UI shows the outage without host access.
|
||||
|
||||
THIS IS NOT DECORATION. #2663 is the record of a telemetry subsystem running
|
||||
at zero for weeks — every counter reading empty, indistinguishable from
|
||||
"nobody uses this" — because every failure went to ``logger.debug``. A
|
||||
subsystem whose failures are all invisible cannot report its own death.
|
||||
|
||||
The AppLog write is itself guarded: when the database is down it fails too,
|
||||
and that is fine. The WARNING already said so, and a canary must never take
|
||||
down the surface it watches.
|
||||
"""
|
||||
logger.warning("%s telemetry %s failed", subsystem, site, exc_info=True)
|
||||
key = f"{subsystem}:{site}"
|
||||
if key in _reported:
|
||||
return
|
||||
_reported.add(key)
|
||||
try:
|
||||
from scribe.services.logging import log_error
|
||||
|
||||
await log_error(
|
||||
endpoint=subsystem,
|
||||
error_type=f"{subsystem}_{site}_failed",
|
||||
error_message=f"{subsystem} telemetry {site} is failing; "
|
||||
"usage counters will read zero until this is fixed",
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("%s canary write failed", subsystem, exc_info=True)
|
||||
|
||||
|
||||
def spawn(coro: Coroutine, *, site: str) -> None:
|
||||
"""Schedule ``coro`` fire-and-forget; ``site`` names it in failure logs.
|
||||
|
||||
@@ -12,6 +12,7 @@ from scribe.models.note_version import NoteVersion
|
||||
from scribe.models.rule_version import RuleVersion
|
||||
from scribe.models.design_system import DesignSystem, DesignToken
|
||||
from scribe.models.note_usage import NoteUsageEvent
|
||||
from scribe.models.rule_usage import RuleUsageEvent
|
||||
from scribe.models.canonical_system import CanonicalSystem
|
||||
from scribe.models.rulebook import RuleRelation, rule_systems as rule_systems_t
|
||||
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
|
||||
@@ -62,8 +63,12 @@ logger = logging.getLogger(__name__)
|
||||
# _COLUMN_EXCLUSIONS and its guard landed with it, so the next such column
|
||||
# fails the build instead.
|
||||
# v13 (2026-08) added rule_versions — a rule's edit history (milestone 323).
|
||||
# v14 (2026-09) added rule_usage_events — the rule twin of note_usage_events
|
||||
# (milestone 333). Carrying it is the WHOLE REASON the table is separate: the
|
||||
# note importer maps note_id through note_id_map, so a rule id parked there
|
||||
# would restore attached to whatever note took that number.
|
||||
# Bump when the serialized schema changes.
|
||||
BACKUP_VERSION = 13
|
||||
BACKUP_VERSION = 14
|
||||
|
||||
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
|
||||
# below, these two lists must together account for the entire schema — which is
|
||||
@@ -92,6 +97,11 @@ _BACKED_UP = [
|
||||
# v13 (2026-08): a rule's edit history (milestone 323). note_versions has
|
||||
# always travelled; its sibling has no excuse not to.
|
||||
"rule_versions",
|
||||
# v14 (2026-09): rule usage telemetry (milestone 333). Same argument
|
||||
# note_usage_events makes for itself — pull-through is only ever
|
||||
# accumulated, so a restore that dropped it would silently reset the
|
||||
# measurement to zero while everything still looked fine.
|
||||
"rule_usage_events",
|
||||
]
|
||||
|
||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||
@@ -178,6 +188,8 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = {
|
||||
"note_supersessions": {"id", "created_at"},
|
||||
"rule_relations": {"id", "created_at"},
|
||||
"note_usage_events": {"id"},
|
||||
# Same as the note twin: the surrogate key is re-issued on insert.
|
||||
"rule_usage_events": {"id"},
|
||||
"design_systems": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"},
|
||||
"design_tokens": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"},
|
||||
"repo_bindings": {"id", "created_at", "updated_at"},
|
||||
@@ -321,6 +333,17 @@ def _usage_event_rows(rows) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def _rule_usage_event_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"user_id": r.user_id, "rule_id": r.rule_id, "event": r.event,
|
||||
"source": r.source,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _code_shape_rows(rows) -> list[dict]:
|
||||
return [r.to_dict() for r in rows]
|
||||
|
||||
@@ -606,6 +629,9 @@ async def export_full_backup() -> dict:
|
||||
)).scalars().all()
|
||||
design_tokens = (await session.execute(select(DesignToken))).scalars().all()
|
||||
usage_events = (await session.execute(select(NoteUsageEvent))).scalars().all()
|
||||
rule_usage_events = (
|
||||
await session.execute(select(RuleUsageEvent))
|
||||
).scalars().all()
|
||||
repo_bindings = (await session.execute(select(RepoBinding))).scalars().all()
|
||||
code_shapes = (await session.execute(select(CodeShape))).scalars().all()
|
||||
code_shape_events = (await session.execute(
|
||||
@@ -665,6 +691,7 @@ async def export_full_backup() -> dict:
|
||||
"design_systems": _design_system_rows(design_systems),
|
||||
"design_tokens": _design_token_rows(design_tokens),
|
||||
"note_usage_events": _usage_event_rows(usage_events),
|
||||
"rule_usage_events": _rule_usage_event_rows(rule_usage_events),
|
||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||
"note_supersessions": _note_supersession_rows(supersessions),
|
||||
"code_shapes": _code_shape_rows(code_shapes),
|
||||
@@ -791,6 +818,14 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
select(RuleVersion).where(RuleVersion.rule_id.in_(_rule_ids))
|
||||
.order_by(RuleVersion.rule_id, RuleVersion.id)
|
||||
)).scalars().all() if _rule_ids else []
|
||||
# Scoped through the RULE for the same reason the versions above are,
|
||||
# and it is worth restating because the column that looks right is
|
||||
# wrong: `user_id` here is whoever the arm fired FOR, not who owns the
|
||||
# rule. Filtering on it would carry this user's surfacings of someone
|
||||
# ELSE's rule and drop the ones fired for someone else on theirs.
|
||||
rule_usage_events = (await session.execute(
|
||||
select(RuleUsageEvent).where(RuleUsageEvent.rule_id.in_(_rule_ids))
|
||||
)).scalars().all() if _rule_ids else []
|
||||
rule_relations = (await session.execute(
|
||||
select(RuleRelation).where(
|
||||
RuleRelation.from_rule_id.in_(_rule_ids),
|
||||
@@ -858,6 +893,7 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"design_systems": _design_system_rows(design_systems),
|
||||
"design_tokens": _design_token_rows(design_tokens),
|
||||
"note_usage_events": _usage_event_rows(usage_events),
|
||||
"rule_usage_events": _rule_usage_event_rows(rule_usage_events),
|
||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||
"note_supersessions": _note_supersession_rows(supersessions),
|
||||
"code_shapes": _code_shape_rows(code_shapes),
|
||||
@@ -994,7 +1030,8 @@ async def _restore_v2(data: dict) -> dict:
|
||||
"rulebook_subscriptions": 0, "rule_suppressions": 0,
|
||||
"topic_suppressions": 0, "rulebook_exclusions": 0,
|
||||
"systems": 0, "record_systems": 0, "design_systems": 0,
|
||||
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
|
||||
"design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0,
|
||||
"repo_bindings": 0,
|
||||
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
|
||||
"code_shape_uses": 0, "canonical_systems": 0,
|
||||
"rule_systems": 0, "rule_relations": 0, "rule_versions": 0,
|
||||
@@ -1496,6 +1533,25 @@ async def _restore_v2(data: dict) -> dict:
|
||||
))
|
||||
stats["note_usage_events"] += 1
|
||||
|
||||
# The rule twin — and the reason it is a separate table at all.
|
||||
# Resolved through rule_id_map, NOT note_id_map. A rule id run through
|
||||
# the note map would either drop (best case) or land on whatever note
|
||||
# took that number, producing telemetry that is wrong rather than
|
||||
# missing and that nothing downstream could detect (milestone 333).
|
||||
# Must come after the rules themselves; rule_id_map is populated there.
|
||||
for ev in data.get("rule_usage_events", []):
|
||||
mapped_rid = rule_id_map.get(ev.get("rule_id", 0))
|
||||
if mapped_rid is None:
|
||||
continue
|
||||
session.add(RuleUsageEvent(
|
||||
user_id=user_id_map.get(ev.get("user_id") or 0),
|
||||
rule_id=mapped_rid,
|
||||
event=ev.get("event", ""),
|
||||
source=ev.get("source", ""),
|
||||
created_at=_dt(ev.get("created_at")),
|
||||
))
|
||||
stats["rule_usage_events"] += 1
|
||||
|
||||
# 20. Repo bindings — small, but losing them means every bound repo
|
||||
# quietly stops loading its project at session start.
|
||||
for rb_data in data.get("repo_bindings", []):
|
||||
|
||||
@@ -344,6 +344,52 @@ def chunk_document(title: str | None, body: str | None) -> list[str]:
|
||||
return chunks
|
||||
|
||||
|
||||
async def _claim_parent_row(session, id_column, row_id: int, label: str) -> bool:
|
||||
"""Lock the record a vector belongs to BEFORE rewriting that vector (#3262).
|
||||
|
||||
An embedding write and a cascading delete of the same record take the same
|
||||
two row locks in OPPOSITE orders. The embedder deletes the old chunk rows
|
||||
and then, on INSERT, needs the foreign key's lock on the parent; a delete
|
||||
of the parent — or of the rulebook, topic or project above it — locks the
|
||||
parent first and cascades down into the chunk rows. That is a cycle, and
|
||||
Postgres breaks it by killing one side at random: sometimes the embedding
|
||||
write, which is swallowed and invisible, and sometimes the operator's
|
||||
delete, which surfaces as a 500 on an operation that should have worked.
|
||||
|
||||
Claiming the parent first REMOVES the cycle rather than narrowing it.
|
||||
Either the embedder arrives first and the delete waits its turn behind it,
|
||||
or the delete already holds the row and NOWAIT makes the embedder lose at
|
||||
once. The embedder is the side that should lose: a skipped refresh costs a
|
||||
stale vector until the next write or the startup backfill, and the other
|
||||
outcome costs a person their request.
|
||||
|
||||
FOR KEY SHARE, not FOR UPDATE — it is precisely the lock the INSERT's
|
||||
foreign key would take anyway, so it conflicts with a delete of the parent
|
||||
and with nothing else. An ordinary edit of the same record, or a second
|
||||
refresh racing this one, is unaffected.
|
||||
|
||||
Returns False when the row is locked or already gone; the caller skips.
|
||||
"""
|
||||
try:
|
||||
held = (await session.execute(
|
||||
select(id_column)
|
||||
.where(id_column == row_id)
|
||||
# BOTH flags: SQLAlchemy spells the four Postgres row locks as a
|
||||
# read/key_share pair, and key_share alone is FOR NO KEY UPDATE —
|
||||
# which would make two refreshes of one record fight each other.
|
||||
.with_for_update(read=True, key_share=True, nowait=True)
|
||||
)).scalar_one_or_none()
|
||||
except Exception:
|
||||
# LockNotAvailable: this record is being deleted right now. Not an
|
||||
# error — the delete wins by design.
|
||||
logger.debug("Skipping embedding for %s %d — row is being deleted", label, row_id)
|
||||
return False
|
||||
if held is None:
|
||||
logger.debug("Skipping embedding for %s %d — row is gone", label, row_id)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def upsert_note_embedding(
|
||||
note_id: int, user_id: int, title: str | None, body: str | None
|
||||
) -> None:
|
||||
@@ -380,6 +426,8 @@ async def upsert_note_embedding(
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
if not await _claim_parent_row(session, Note.id, note_id, "note"):
|
||||
return
|
||||
await session.execute(
|
||||
delete(NoteEmbedding).where(NoteEmbedding.note_id == note_id)
|
||||
)
|
||||
@@ -400,6 +448,23 @@ async def upsert_note_embedding(
|
||||
logger.warning("Failed to persist embedding for note %d", note_id, exc_info=True)
|
||||
|
||||
|
||||
# Both searches rank WITHOUT the threshold and apply it in Python, so the best
|
||||
# rejected score stays observable (#3670). The qualifying set is provably
|
||||
# unchanged: rows arrive ordered by distance ascending, so every above-bar row
|
||||
# sorts ahead of every below-bar one, and an over-fetch that used to return N
|
||||
# above-bar rows returns the same N plus some losers. What changes is only that
|
||||
# the losers are now visible instead of discarded inside the query.
|
||||
#
|
||||
# That visibility is the entire point. A bar can only be judged from the calls
|
||||
# it TURNED AWAY — a 0.72 bar rejecting a stream of 0.71s is set too high by a
|
||||
# hair, one rejecting 0.30s is working — and those two are indistinguishable
|
||||
# from any arrangement of the columns that survive the filter.
|
||||
#
|
||||
# `report` is how the score gets out without changing what a search RETURNS.
|
||||
# Eight of the eleven call sites want hits and nothing else; the three that
|
||||
# write telemetry pass a dict and read `best_available_score` back out of it.
|
||||
|
||||
|
||||
async def semantic_search_notes(
|
||||
user_id: int,
|
||||
query: str,
|
||||
@@ -414,12 +479,19 @@ async def semantic_search_notes(
|
||||
scope: str = "own",
|
||||
demote_superseded: bool = True,
|
||||
system_id: int | None = None,
|
||||
report: dict | None = None,
|
||||
) -> list[tuple[float, Note]]:
|
||||
"""Return up to *limit* (score, note) pairs most relevant to *query*.
|
||||
|
||||
Scores are cosine similarities in [-1, 1]; only notes at or above
|
||||
*threshold* are returned, sorted highest-first.
|
||||
|
||||
Pass `report` (an empty dict) to learn what the threshold turned away:
|
||||
the function sets `report["best_available_score"]` to the highest score
|
||||
anything reached, or None when the corpus offered nothing at all. It is
|
||||
the only figure that survives a call returning nothing, and therefore the
|
||||
only one a bar can be judged from (#3670).
|
||||
|
||||
`note_type` narrows to a record kind, or several (e.g. "snippet", or
|
||||
("snippet", "note")), for callers that want prior art rather than everything
|
||||
embedded.
|
||||
@@ -465,7 +537,6 @@ async def semantic_search_notes(
|
||||
# Distance ceiling equivalent to the similarity floor. Clamp to the valid
|
||||
# cosine-distance range [0, 2] so a threshold of, say, -1 doesn't produce a
|
||||
# nonsensical ceiling.
|
||||
max_distance = min(2.0, max(0.0, 1.0 - threshold))
|
||||
distance = NoteEmbedding.embedding.cosine_distance(query_vec)
|
||||
|
||||
try:
|
||||
@@ -540,11 +611,10 @@ async def semantic_search_notes(
|
||||
fetch = limit * _CHUNK_OVERFETCH * (
|
||||
_SUPERSESSION_OVERFETCH if demote_superseded else 1
|
||||
)
|
||||
stmt = (
|
||||
stmt.where(distance <= max_distance)
|
||||
.order_by(distance.asc())
|
||||
.limit(fetch)
|
||||
)
|
||||
# NO threshold predicate — see the note above this function. The
|
||||
# bar is applied after the collapse, where the rejected scores can
|
||||
# still be seen.
|
||||
stmt = stmt.order_by(distance.asc()).limit(fetch)
|
||||
rows = list((await session.execute(stmt)).all())
|
||||
except Exception:
|
||||
logger.warning("Failed to query note embeddings", exc_info=True)
|
||||
@@ -563,6 +633,11 @@ async def semantic_search_notes(
|
||||
continue
|
||||
seen.add(int(note.id))
|
||||
scored.append((1.0 - float(dist), note))
|
||||
# The best score anything reached, bar or no bar. Recorded BEFORE the
|
||||
# filter because a call that returns nothing is exactly when it matters.
|
||||
if report is not None:
|
||||
report["best_available_score"] = scored[0][0] if scored else None
|
||||
scored = [pair for pair in scored if pair[0] >= threshold]
|
||||
if not demote_superseded:
|
||||
return scored[:limit]
|
||||
return await _apply_supersession_penalty(scored, limit)
|
||||
@@ -666,6 +741,8 @@ async def upsert_rule_embedding(
|
||||
replacement is atomic per rule so a concurrent read sees the old chunk set
|
||||
or the new one, never a mixture.
|
||||
"""
|
||||
from scribe.models.rulebook import Rule # runtime import: see TYPE_CHECKING above
|
||||
|
||||
doc_title, doc_body = rule_document(title, statement, when_to_apply)
|
||||
chunks = chunk_document(doc_title, doc_body)
|
||||
try:
|
||||
@@ -688,6 +765,8 @@ async def upsert_rule_embedding(
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
if not await _claim_parent_row(session, Rule.id, rule_id, "rule"):
|
||||
return
|
||||
await session.execute(
|
||||
delete(RuleEmbedding).where(RuleEmbedding.rule_id == rule_id)
|
||||
)
|
||||
@@ -712,9 +791,16 @@ async def semantic_search_rules(
|
||||
limit: int = 5,
|
||||
threshold: float = _SIMILARITY_THRESHOLD,
|
||||
tier: str | None = None,
|
||||
report: dict | None = None,
|
||||
) -> list[tuple[float, "Rule"]]:
|
||||
"""Return up to *limit* (score, rule) pairs most relevant to *query*.
|
||||
|
||||
Pass `report` (an empty dict) to learn what the threshold turned away:
|
||||
the function sets `report["best_available_score"]` to the highest score
|
||||
anything reached, or None when the corpus offered nothing at all. It is
|
||||
the only figure that survives a call returning nothing, and therefore the
|
||||
only one a bar can be judged from (#3670).
|
||||
|
||||
Scoped by OWNERSHIP — a rule is the caller's if they own its rulebook or
|
||||
its project. Deliberately not filtered to what currently BINDS a given
|
||||
project: this answers "is there a rule about this", which a person asking
|
||||
@@ -722,10 +808,17 @@ async def semantic_search_rules(
|
||||
is the surfacing question, and it has its own machinery
|
||||
(get_applicable_rules) rather than a second, subtly different copy here.
|
||||
|
||||
`tier` narrows to one tier. The write-path hint passes "conditional",
|
||||
because an always-on rule is ALREADY in the session — surfacing it again as
|
||||
a suggestion is pure noise, and noise on a hint that fires on every write
|
||||
is how a hint gets ignored.
|
||||
`tier` narrows to one tier, and NONE is the ordinary case. The write-path
|
||||
and pre-tool hints deliberately pass nothing: an always-on rule is already
|
||||
in the session, but being in a list from turn zero is not the same as being
|
||||
in front of the reader when the action it governs is taken, and filtering
|
||||
on tier made a whole class of rules permanently ineligible for the one
|
||||
mechanism that surfaces a rule AT the moment. Relevance is the threshold's
|
||||
job; see the block above RULEHINT_LIMIT in services/plugin_context.py for
|
||||
the argument and for what the resulting scores are being read against.
|
||||
|
||||
Pass a tier when a caller genuinely wants one class — a listing, an audit,
|
||||
a UI that renders the tiers apart. Not to approximate relevance.
|
||||
|
||||
Collapses to best-chunk-per-rule like the note search, so a long rule split
|
||||
across chunks competes once rather than crowding the results with itself.
|
||||
@@ -743,7 +836,6 @@ async def semantic_search_rules(
|
||||
logger.debug("Rule search skipped — embedder unavailable")
|
||||
return []
|
||||
|
||||
max_distance = min(2.0, max(0.0, 1.0 - threshold))
|
||||
distance = RuleEmbedding.embedding.cosine_distance(query_vec)
|
||||
|
||||
try:
|
||||
@@ -757,7 +849,8 @@ async def semantic_search_rules(
|
||||
.outerjoin(Project, Rule.project_id == Project.id)
|
||||
.where(
|
||||
Rule.deleted_at.is_(None),
|
||||
distance <= max_distance,
|
||||
# No threshold predicate — see the note above
|
||||
# semantic_search_notes. Applied below, after the collapse.
|
||||
# topic_id XOR project_id, so exactly one arm can match.
|
||||
or_(
|
||||
Rulebook.owner_user_id == user_id,
|
||||
@@ -780,7 +873,9 @@ async def semantic_search_rules(
|
||||
if rule.id not in best or score > best[rule.id][0]:
|
||||
best[rule.id] = (score, rule)
|
||||
ranked = sorted(best.values(), key=lambda pair: pair[0], reverse=True)
|
||||
return ranked[:limit]
|
||||
if report is not None:
|
||||
report["best_available_score"] = ranked[0][0] if ranked else None
|
||||
return [pair for pair in ranked if pair[0] >= threshold][:limit]
|
||||
|
||||
|
||||
async def backfill_rule_embeddings() -> None:
|
||||
|
||||
@@ -30,13 +30,13 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
|
||||
from scribe.models.base import iso
|
||||
from scribe.services.background import report_telemetry_failure
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,37 +46,19 @@ logger = logging.getLogger(__name__)
|
||||
# never lands. The done-callback discard keeps the set from growing.
|
||||
_pending: set[asyncio.Task] = set()
|
||||
|
||||
# Sites that already dropped their once-per-process AppLog row. The readout
|
||||
# runs on every snippet list render — without this, a broken table would turn
|
||||
# the error log into a firehose that buries the finding it exists to surface.
|
||||
_reported: set[str] = set()
|
||||
|
||||
|
||||
async def _report_failure(site: str) -> None:
|
||||
"""Make a swallowed telemetry failure visible. Called from an except block.
|
||||
"""This subsystem's canary, now the shared one.
|
||||
|
||||
WARNING to the process log every time; one AppLog error row per process per
|
||||
site so the admin UI shows the outage without host access. The AppLog write
|
||||
is itself guarded — when the whole database is down it fails too, and that
|
||||
is fine: the WARNING already said so, and a canary must never take down the
|
||||
surface it watches.
|
||||
The per-site dedup, the WARNING and the single AppLog row all moved to
|
||||
`background.report_telemetry_failure` unchanged when `rule_usage` needed
|
||||
the identical behaviour — two hand-kept copies of a thing whose whole job
|
||||
is to be reliable is the wrong number. `retrieval_telemetry` deliberately
|
||||
still has its own: its canary is a different shape (one process-wide flag,
|
||||
no AppLog row), so repointing it would change behaviour rather than
|
||||
consolidate it.
|
||||
"""
|
||||
logger.warning("note usage telemetry %s failed", site, exc_info=True)
|
||||
if site in _reported:
|
||||
return
|
||||
_reported.add(site)
|
||||
try:
|
||||
from scribe.services.logging import log_error
|
||||
|
||||
await log_error(
|
||||
endpoint="note_usage",
|
||||
error_type=f"note_usage_{site}_failed",
|
||||
error_message=f"note usage telemetry {site} is failing; "
|
||||
"usage counters will read zero until this is fixed",
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("note usage canary write failed", exc_info=True)
|
||||
await report_telemetry_failure("note_usage", site)
|
||||
|
||||
|
||||
async def _insert_events(rows: list[dict]) -> None:
|
||||
|
||||
@@ -76,6 +76,10 @@ def embed_note(note) -> None:
|
||||
exceptions are swallowed because a record that saved must not fail on its
|
||||
index refresh. No running loop (unit tests, scripts) is an ordinary case,
|
||||
not an error.
|
||||
|
||||
Detaching also means this task races anything that deletes the note out
|
||||
from under it. That is not handled here: `upsert_note_embedding` claims
|
||||
the note's row before touching its vectors, and loses if it can't (#3262).
|
||||
"""
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
@@ -60,7 +60,7 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict:
|
||||
|
||||
return {
|
||||
"milestone": milestone.to_dict(),
|
||||
**rulebooks_svc.rules_payload(applicable),
|
||||
**rulebooks_svc.rules_payload(applicable, user_id=user_id, source="start_planning"),
|
||||
"project_goal": getattr(project, "goal", "") or "",
|
||||
"open_task_count": open_count,
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ from scribe.services import snippets as snippets_svc
|
||||
from scribe.services.access import label_shared_items, owner_names_for
|
||||
from scribe.services.embeddings import semantic_search_notes, semantic_search_rules
|
||||
from scribe.services.note_usage import record_surfaced
|
||||
from scribe.services.rule_usage import record_rule_surfaced
|
||||
from scribe.services.supersession import superseded_ids
|
||||
from scribe.services.retrieval_telemetry import record_retrieval
|
||||
from scribe.services.settings import get_setting
|
||||
@@ -85,6 +86,117 @@ WRITEPATH_THRESHOLD_KEY = "kb_writepath_threshold"
|
||||
WRITEPATH_DEFAULT_ENABLED = True
|
||||
WRITEPATH_DEFAULT_THRESHOLD = 0.68
|
||||
|
||||
# The standing-rule arm (milestone 307) gets its own bar — the split #2223 made
|
||||
# one surface down, now made for the THIRD corpus. It inherited 0.68 above, and
|
||||
# that number was measured against code-vs-note-PROSE. It was never re-derived
|
||||
# for code-vs-RULE-TEXT.
|
||||
#
|
||||
# THE STRUCTURAL ARGUMENT, which is the only kind admissible here (rule 115).
|
||||
# Two facts hold on any install, including one with six rules and no telemetry:
|
||||
#
|
||||
# 1. The eligible corpus is SMALL — every rule an install owns, still only
|
||||
# a few dozen documents against thousands of notes. A top-k over a small
|
||||
# pool always returns something, so "the best match cleared the bar"
|
||||
# drifts from "a good match exists" toward "N things were ranked". A bar
|
||||
# calibrated for best-of-thousands is cleared by best-of-forty as
|
||||
# arithmetic rather than relevance.
|
||||
# This argument WEAKENED when the arms stopped filtering to one tier
|
||||
# (see the note on that below): a larger pool makes clearing the bar
|
||||
# mean more, not less. The threshold was deliberately left where it was
|
||||
# anyway — moving two variables at once would make the resulting
|
||||
# distribution unreadable, and this one errs toward silence on purpose.
|
||||
# 2. Rules are short imperative technical English — a far more HOMOGENEOUS
|
||||
# corpus than note prose. #2223 measured the floor for code against prose
|
||||
# at 0.55-0.63 and set 0.68 above it. A more homogeneous corpus has a
|
||||
# HIGHER floor, so 0.68 is not merely inherited, it is below where this
|
||||
# corpus's noise sits.
|
||||
#
|
||||
# WHY 0.72 AND NOT A NUMBER OFF A HISTOGRAM. The exact offset between prose's
|
||||
# floor and rule-text's is not derivable in general — it depends on how an
|
||||
# install writes its rules — so the default errs deliberately toward SILENCE
|
||||
# rather than toward recall, on an asymmetry that is itself structural: this
|
||||
# hint fires on EVERY write. A missed rule is recoverable, because the rule is
|
||||
# still in Scribe and the agent can search it. A hint that cries wolf is not:
|
||||
# it teaches the reader to skip the whole block, and the surface is lost along
|
||||
# with the true positives it would have carried. The arm's own comment already
|
||||
# says "noise on a hint that fires on every write is how a hint gets ignored".
|
||||
#
|
||||
# TUNE IT FROM YOUR OWN INSTANCE, which is now possible: `retrieval_telemetry`
|
||||
# reports `rule_usage.pull_through` (milestone 333 step 3). Raise this if rules
|
||||
# arrive unread; lower it if rules you needed never arrived. What would RETIRE
|
||||
# it: a cross-encoder rerank (#1038), which would make a similarity bar the
|
||||
# wrong control entirely.
|
||||
RULEHINT_THRESHOLD_KEY = "kb_rulehint_threshold"
|
||||
RULEHINT_DEFAULT_THRESHOLD = 0.72
|
||||
|
||||
# ONE rule per write, not two — and this is deliberately NOT a knob.
|
||||
#
|
||||
# With a corpus this small, top-k does as much damage as the threshold: k=2
|
||||
# over a few dozen candidates means the second line is almost always the
|
||||
# second-best noise, arriving with the same confident framing as the first.
|
||||
# Halving k halves that regardless of where the bar sits.
|
||||
#
|
||||
# It also BOUNDS the blast radius of widening the pool (below): with k=1 a
|
||||
# wider corpus can change WHICH rule surfaces and how often one does, but it
|
||||
# can never make a single hint longer. The loudness of one hint and the
|
||||
# eligibility of a rule are separate controls, and only one of them moved.
|
||||
#
|
||||
# It stays a constant because it is a decision about how LOUD one hint may be,
|
||||
# not a per-install tuning question. The hint already carries prior art, shape
|
||||
# signals and staleness; rules are the fourth voice in it, and a fourth voice
|
||||
# that speaks twice is where a reader stops reading. Nothing suggests an
|
||||
# operator wants this different, and a knob nobody turns is a knob that only
|
||||
# adds a way to misconfigure the surface (rule 25 cuts both ways).
|
||||
RULEHINT_LIMIT = 1
|
||||
|
||||
# WHY THE ARMS NO LONGER FILTER TO ONE TIER (#3702).
|
||||
#
|
||||
# Both arms used to pass `tier="conditional"`, on the reasoning that an
|
||||
# always-on rule is already in the session, so surfacing it again is pure
|
||||
# noise. That reasoning conflates two different things:
|
||||
#
|
||||
# PRESENT IN CONTEXT — the rule was delivered at session start.
|
||||
# SALIENT AT THE MOMENT — the rule is in front of the reader when the
|
||||
# action it governs is about to be taken.
|
||||
#
|
||||
# A rule handed over in a list at turn zero is present while a session writes
|
||||
# a config value three hundred turns later. It is not surfaced. So the filter
|
||||
# did not merely skip a redundant hint — it made a whole class of rules
|
||||
# permanently ineligible for the only mechanism that puts a rule in front of
|
||||
# an agent AT the moment, and the more important a rule is, the more likely
|
||||
# it was in that class.
|
||||
#
|
||||
# The deeper defect is that the filter was doing the THRESHOLD's job. Whether
|
||||
# a rule belongs in this hint is a relevance question, and a similarity bar is
|
||||
# the control for relevance. A categorical exclusion standing in for a
|
||||
# relevance judgment cannot be tuned, cannot be measured, and cannot be wrong
|
||||
# in a way anybody notices.
|
||||
#
|
||||
# THIS IS A MEASURED CHANGE, NOT A SETTLED ONE. The old comment's fear is
|
||||
# real — a hint that fires on every write and says obvious things teaches the
|
||||
# reader to skip the block, and the surface is then lost along with its true
|
||||
# positives. That fear had simply never been checked. `retrieval_logs` already
|
||||
# records top_score, result_count and the query for every call, so the
|
||||
# evidence now arrives on its own:
|
||||
#
|
||||
# - rules clear the bar often and at high scores -> the fear was justified,
|
||||
# the filter was a crude proxy for a bar set too low, and the WORK IS THE
|
||||
# BAR. Any reinstated filter should then carry a measured reason.
|
||||
# - rules clear rarely, in a thin band near the bar -> the filter was never
|
||||
# the right instrument and relevance was always sufficient.
|
||||
#
|
||||
# Only the eligibility moved. The bar and k=1 were both left exactly where
|
||||
# they were, so the resulting distribution has one cause.
|
||||
|
||||
# How much of a command reaches the embedding (#3476). A shell call is not a
|
||||
# file: most are short, and the ones that are not are usually a heredoc or a
|
||||
# pasted script whose bulk says nothing about which rule applies. The VERB AND
|
||||
# ITS TARGET sit at the front — `curl https://git.fabledsword.com/api/...`,
|
||||
# `docker compose up`, `git checkout -b` — and that head is the whole signal.
|
||||
# Sending the tail as well would push it out of a 512-token window and let a
|
||||
# heredoc's prose decide the match.
|
||||
_TOOL_QUERY_CHARS = 400
|
||||
|
||||
# Minimum SUBSTANCE (non-whitespace chars) a payload must carry before the
|
||||
# semantic arm will run at all — the cheap half of the operator's #89 idea
|
||||
# ("a sliding scale between number of characters and semantic threshold").
|
||||
@@ -351,6 +463,7 @@ async def _reserve_slot_for_reuse(
|
||||
|
||||
top_k = cfg["top_k"]
|
||||
_t0 = time.perf_counter()
|
||||
_rep: dict = {}
|
||||
reuse = await semantic_search_notes(
|
||||
user_id, query,
|
||||
limit=1,
|
||||
@@ -359,6 +472,7 @@ async def _reserve_slot_for_reuse(
|
||||
exclude_ids=exclude_ids | {int(n.id) for _s, n in kept},
|
||||
note_type=_REUSE_KINDS,
|
||||
scope="browse",
|
||||
report=_rep,
|
||||
)
|
||||
# A real semantic query competing for a menu slot — logged like the scored
|
||||
# arm it displaces. Before this, the hit it PUSHED OUT was in
|
||||
@@ -369,6 +483,7 @@ async def _reserve_slot_for_reuse(
|
||||
user_id=user_id, source="reuse_slot", query=query,
|
||||
threshold=cfg["threshold"], limit=1, project_id=project_id,
|
||||
is_task=None, results=reuse,
|
||||
best_available=_rep.get("best_available_score"),
|
||||
duration_ms=(time.perf_counter() - _t0) * 1000.0,
|
||||
)
|
||||
# Verify the kind rather than trusting the query that asked for it, and
|
||||
@@ -418,6 +533,7 @@ async def build_autoinject_hint(
|
||||
return empty
|
||||
|
||||
t0 = time.perf_counter()
|
||||
_rep_ai: dict = {}
|
||||
hits = await semantic_search_notes(
|
||||
user_id, q,
|
||||
limit=cfg["top_k"],
|
||||
@@ -429,11 +545,13 @@ async def build_autoinject_hint(
|
||||
# still appear is a collaborator's note inside a shared project — legible
|
||||
# only because the line below names its owner.
|
||||
scope="browse",
|
||||
report=_rep_ai,
|
||||
)
|
||||
record_retrieval(
|
||||
user_id=user_id, source="auto_inject", query=q,
|
||||
threshold=cfg["threshold"], limit=cfg["top_k"],
|
||||
project_id=(project_id or None), is_task=None, results=hits,
|
||||
best_available=_rep_ai.get("best_available_score"),
|
||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||
)
|
||||
if not hits:
|
||||
@@ -690,10 +808,19 @@ async def get_writepath_config(user_id: int) -> dict:
|
||||
threshold = WRITEPATH_DEFAULT_THRESHOLD
|
||||
threshold = min(1.0, max(0.0, threshold))
|
||||
|
||||
try:
|
||||
rule_threshold = float(await get_setting(
|
||||
user_id, RULEHINT_THRESHOLD_KEY, str(RULEHINT_DEFAULT_THRESHOLD)))
|
||||
except (TypeError, ValueError):
|
||||
rule_threshold = RULEHINT_DEFAULT_THRESHOLD
|
||||
rule_threshold = min(1.0, max(0.0, rule_threshold))
|
||||
|
||||
return {
|
||||
**cfg,
|
||||
"enabled": enabled_raw.strip().lower() in ("true", "1", "yes", "on"),
|
||||
"threshold": threshold,
|
||||
# Its own bar, for a third corpus — see RULEHINT_DEFAULT_THRESHOLD.
|
||||
"rule_threshold": rule_threshold,
|
||||
}
|
||||
|
||||
|
||||
@@ -851,6 +978,7 @@ async def build_write_path_hint(
|
||||
# Pulled-and-seen ids stay in the query (as evidence) but never in
|
||||
# the menu — the dedup contract holds, the resemblance still lands.
|
||||
pulled_seen = seen & set(pulled)
|
||||
_rep_wp: dict = {}
|
||||
hits = await semantic_search_notes(
|
||||
user_id, query,
|
||||
limit=remaining + len(pulled_seen),
|
||||
@@ -874,12 +1002,40 @@ async def build_write_path_hint(
|
||||
# Same reasoning as auto-inject: nobody asked for this, so it takes
|
||||
# the browse scope and never surfaces a one-to-one direct share.
|
||||
scope="browse",
|
||||
report=_rep_wp,
|
||||
)
|
||||
resembles = {
|
||||
int(note.id): float(score) for score, note in hits
|
||||
if int(note.id) in pulled
|
||||
}
|
||||
hits = [(s, n) for s, n in hits if int(n.id) not in seen][:remaining]
|
||||
shown = [(s, n) for s, n in hits if int(n.id) not in seen]
|
||||
# WHAT THIS ARM WITHHELD AFTER THE SEARCH ANSWERED, and the reason
|
||||
# `best_available_score` cannot always be reported here (#3739 again,
|
||||
# from the side its fix did not reach).
|
||||
#
|
||||
# This arm is the one note arm that filters TWICE. `exclude_ids` takes
|
||||
# `seen - pulled_seen` into the search, but the pulled-and-seen ids stay
|
||||
# in the query deliberately — `resembles` above needs them — and are
|
||||
# dropped in the line above instead. So the score the search reported is
|
||||
# PRE that drop while the row's `result_count` is POST it, and a record
|
||||
# the session had already been shown could be logged as something the
|
||||
# BAR turned away. Live proof on the first read after #3739 shipped:
|
||||
# write_path's near-miss max was 0.822 while the lowest score it ever
|
||||
# RETURNED was 0.6857 — a "rejection" that beat every acceptance.
|
||||
#
|
||||
# The suppression column cannot rescue it the way it does for the rule
|
||||
# arms: this arm's count would be PARTIAL, covering only the drops made
|
||||
# here and not the ones `exclude_ids` made inside the search, and a
|
||||
# partial number under a name that reads as complete is the substitution
|
||||
# this whole milestone exists to stop.
|
||||
#
|
||||
# So the honest answer is null — "not measured on this call" — whenever
|
||||
# this filter removed anything, because then the bar is not the only
|
||||
# thing that turned something away and the reported score may belong to
|
||||
# a record we withheld ourselves. Calls where nothing was dropped keep
|
||||
# reporting it, which is most of them.
|
||||
withheld_here = len(hits) - len(shown)
|
||||
hits = shown[:remaining]
|
||||
record_retrieval(
|
||||
user_id=user_id, source="write_path", query=query,
|
||||
threshold=cfg["threshold"], limit=remaining,
|
||||
@@ -887,6 +1043,9 @@ async def build_write_path_hint(
|
||||
# recording it as a notes-only retrieval would misdescribe the
|
||||
# candidate set the threshold is being tuned against.
|
||||
project_id=scope_project, is_task=None, results=hits,
|
||||
best_available=(
|
||||
None if withheld_here else _rep_wp.get("best_available_score")
|
||||
),
|
||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||
)
|
||||
if hits:
|
||||
@@ -1125,10 +1284,18 @@ async def build_write_path_hint(
|
||||
rule_ids: list[int] = []
|
||||
try:
|
||||
already = set(exclude_rule_ids or [])
|
||||
# Timed like the notes arm above. Without this the rule row was the one
|
||||
# source in the whole readout reporting a null p90_duration_ms (#3311)
|
||||
# — a gap that reads as "this surface is somehow not measurable" rather
|
||||
# than "nobody passed the number".
|
||||
rule_t0 = time.perf_counter()
|
||||
_rep_wpr: dict = {}
|
||||
hits = await semantic_search_rules(
|
||||
user_id, code or path, limit=2,
|
||||
threshold=cfg["threshold"], tier="conditional",
|
||||
user_id, code or path, limit=RULEHINT_LIMIT,
|
||||
threshold=cfg["rule_threshold"],
|
||||
report=_rep_wpr,
|
||||
)
|
||||
rule_ms = (time.perf_counter() - rule_t0) * 1000.0
|
||||
fresh = [(score, rule) for score, rule in hits if rule.id not in already]
|
||||
for _score, rule in fresh:
|
||||
trigger = (rule.when_to_apply or "").strip()
|
||||
@@ -1139,15 +1306,55 @@ async def build_write_path_hint(
|
||||
"does not apply; it is not in this session's loaded set."
|
||||
)
|
||||
rule_ids.append(rule.id)
|
||||
if fresh:
|
||||
# retrieval_logs, NOT note_usage_events: that table's ids are
|
||||
# remapped on a backup restore, so a rule id there would return
|
||||
# attached to whatever note took that number. This one is never
|
||||
# restored, and `source` already separates the surfaces.
|
||||
# TWO tables, and the split is not arbitrary. retrieval_logs is one
|
||||
# row per CALL, keyed on the score distribution a threshold is tuned
|
||||
# from. rule_usage_events is one row per RULE per event, which is the
|
||||
# grain "was this hint ever acted on" needs and the grain a JSONB
|
||||
# result_ids array cannot be indexed at.
|
||||
#
|
||||
# This comment used to say rule ids had nowhere to go — that
|
||||
# note_usage_events remaps ids on restore, so a rule id there would
|
||||
# return attached to whatever note took that number. That is still
|
||||
# true of the NOTE table, and it is exactly why rule_usage_events is
|
||||
# its own (milestone 333 step 1). The gap it described is closed.
|
||||
#
|
||||
# THE CALL LOG IS UNCONDITIONAL; THE SURFACING LOG IS NOT, and the
|
||||
# asymmetry is the correction #3497 exists to make. Both used to sit
|
||||
# inside an `if fresh:`, which is how this arm came to report
|
||||
# `zero_result_calls: 0` and `cleared_threshold: 133/133` — not a
|
||||
# perfectly tuned surface but one structurally unable to record its
|
||||
# own misses. #3311 read that artifact as a measurement and a whole
|
||||
# milestone was scoped on it. A call that found nothing is the ONLY
|
||||
# evidence a threshold is set too high, and it is the row every note
|
||||
# surface has always written (write_path: 421 zeroes of 613 calls;
|
||||
# auto_inject: 114 of 326). A SURFACING is different in kind: nothing
|
||||
# was shown, so no such event occurred, and its log stays guarded.
|
||||
#
|
||||
# `results=fresh`, not `hits`: the note arms pass their exclusions
|
||||
# INTO semantic_search_notes, so what they log is already
|
||||
# post-exclusion. semantic_search_rules takes no such parameter and
|
||||
# this filter is where the equivalent happens — logging `hits` would
|
||||
# quietly make this row mean something other than every other row in
|
||||
# the same readout.
|
||||
record_retrieval(
|
||||
user_id=user_id, source="write_path_rule", query=code or path,
|
||||
threshold=cfg["threshold"], limit=2, project_id=project_id,
|
||||
is_task=None, results=fresh,
|
||||
threshold=cfg["rule_threshold"], limit=RULEHINT_LIMIT,
|
||||
project_id=project_id,
|
||||
is_task=None, results=fresh, duration_ms=rule_ms,
|
||||
best_available=_rep_wpr.get("best_available_score"),
|
||||
# What the ranker found and this session had already been told.
|
||||
# Without it a zero row cannot say whether the bar was too high or
|
||||
# the reader was simply ahead of it — and only the first is a
|
||||
# reason to move the threshold.
|
||||
suppressed=len(hits) - len(fresh),
|
||||
)
|
||||
if fresh:
|
||||
# `rule_ids` is `fresh`, i.e. AFTER exclude_rule_ids. A rule the
|
||||
# session already holds was considered and not shown, and counting
|
||||
# it would inflate the denominator with claims the agent never saw
|
||||
# — which reads as a precision problem this arm does not have.
|
||||
record_rule_surfaced(
|
||||
user_id=user_id, rule_ids=rule_ids, source="write_path_rule",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("write-path rule arm failed", exc_info=True)
|
||||
@@ -1165,6 +1372,117 @@ async def build_write_path_hint(
|
||||
}
|
||||
|
||||
|
||||
async def build_tool_rule_hint(
|
||||
user_id: int,
|
||||
tool_name: str,
|
||||
command: str,
|
||||
*,
|
||||
project_id: int = 0,
|
||||
exclude_rule_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Standing rules that may apply to the ACTION about to be taken (#3476).
|
||||
|
||||
The sibling of the write-path rule arm, and the surface that was missing.
|
||||
That arm is keyed on `code or path`, so a rule can only be retrieved at the
|
||||
moment of a code WRITE. Every rule about which tool to reach for — don't
|
||||
curl the forge, don't stand up a stack, don't run the suite locally, don't
|
||||
branch — was therefore unreachable at the moment it mattered, and residency
|
||||
in the always-on preload was the only surface it had.
|
||||
|
||||
WHY A MECHANICAL TRIGGER AND NOT AN INSTRUCTION. Note #3089's finding is
|
||||
that a reflex generates no query: you reach for `curl` confidently, with no
|
||||
moment of doubt, so any surface that waits to be asked never fires. Here
|
||||
nothing has to be asked — the tool call IS the query, and the reflex has to
|
||||
become a tool call before it can do anything.
|
||||
|
||||
Deliberately TOOL-AGNOSTIC: takes a name and a string. The hook decides
|
||||
which tools it watches, so widening the matcher is a `hooks.json` edit with
|
||||
no change here.
|
||||
|
||||
CONDITIONAL ONLY, exactly as the write-path arm — an always-on rule is
|
||||
already resident and repeating it is noise. That filter is also the
|
||||
transition this arm exists to enable: re-tier a rule to `conditional` and
|
||||
it starts arriving here instead of in every session's preamble.
|
||||
|
||||
Fails open and returns an empty context on any error: a recall aid may
|
||||
never break the operator's action.
|
||||
"""
|
||||
out: dict = {"context": "", "rule_ids": []}
|
||||
command = (command or "").strip()
|
||||
if not command:
|
||||
return out
|
||||
|
||||
try:
|
||||
cfg = await get_writepath_config(user_id)
|
||||
if not cfg.get("enabled"):
|
||||
return out
|
||||
|
||||
# The command text is the query. A long heredoc or a pasted script
|
||||
# would otherwise push the meaningful head of the command out of the
|
||||
# embedding window, so it is bounded — the verb and its target sit at
|
||||
# the front, which is the part a rule is about.
|
||||
query = command[:_TOOL_QUERY_CHARS]
|
||||
|
||||
t0 = time.perf_counter()
|
||||
_rep_ptr: dict = {}
|
||||
hits = await semantic_search_rules(
|
||||
user_id, query, limit=RULEHINT_LIMIT,
|
||||
threshold=cfg["rule_threshold"],
|
||||
report=_rep_ptr,
|
||||
)
|
||||
duration_ms = (time.perf_counter() - t0) * 1000.0
|
||||
|
||||
already = set(exclude_rule_ids or [])
|
||||
fresh = [(score, rule) for score, rule in hits if rule.id not in already]
|
||||
|
||||
# Logged BEFORE the early return, for the reason spelled out at length
|
||||
# on the write-path arm above: a call that found nothing is the only
|
||||
# evidence a threshold is too high, and an arm that logs only the calls
|
||||
# it liked reports a flawless clear-rate however badly it is tuned.
|
||||
# This arm shipped with the same defect inherited from its sibling, and
|
||||
# it mattered more here — a surface with no rows at all cannot be told
|
||||
# apart from a hook that never fired, which is precisely the silent
|
||||
# failure the arm was built to stop.
|
||||
record_retrieval(
|
||||
user_id=user_id, source="pre_tool_rule", query=query,
|
||||
threshold=cfg["rule_threshold"], limit=RULEHINT_LIMIT,
|
||||
project_id=project_id,
|
||||
is_task=None, results=fresh, duration_ms=duration_ms,
|
||||
best_available=_rep_ptr.get("best_available_score"),
|
||||
# See the sibling arm. It matters more here: this arm fires on every
|
||||
# Bash call, so a long session excludes its way to an all-zero row
|
||||
# and the threshold looks wrong when nothing about it is.
|
||||
suppressed=len(hits) - len(fresh),
|
||||
)
|
||||
if not fresh:
|
||||
return out
|
||||
|
||||
lines: list[str] = []
|
||||
rule_ids: list[int] = []
|
||||
for _score, rule in fresh:
|
||||
trigger = (rule.when_to_apply or "").strip()
|
||||
lines.append(
|
||||
f"Standing rule that may apply to this {tool_name} call — "
|
||||
f"“{rule.title}”"
|
||||
+ (f" ({trigger})" if trigger else "")
|
||||
+ f". Read it with get_rule({rule.id}) before deciding it "
|
||||
"does not apply; it is not in this session's loaded set."
|
||||
)
|
||||
rule_ids.append(rule.id)
|
||||
|
||||
# RANKED, not ambient: this arm chose what it showed, so a pull can
|
||||
# settle whether the choice was any good. `rule_usage.RANKED_SOURCES`
|
||||
# carries the same name.
|
||||
record_rule_surfaced(
|
||||
user_id=user_id, rule_ids=rule_ids, source="pre_tool_rule",
|
||||
)
|
||||
out["context"] = "\n".join(lines)
|
||||
out["rule_ids"] = rule_ids
|
||||
except Exception:
|
||||
logger.debug("pre-tool rule arm failed", exc_info=True)
|
||||
return out
|
||||
|
||||
|
||||
def _derive_line(path: str, derive: list[dict]) -> str:
|
||||
"""The ledger's word on the names being written (#2900): a duplicate
|
||||
family to derive, or a canon to reuse — said at the write."""
|
||||
@@ -1290,6 +1608,24 @@ async def build_session_context(
|
||||
# exclusion (milestone 297) takes a rulebook out of this block, and is
|
||||
# named below so the departure is visible rather than silent.
|
||||
rules = await rulebooks_svc.list_always_on_rules(user_id, project_id=project_id)
|
||||
# AMBIENT source, and the one that matters most: this is the preload — the
|
||||
# block every session opens with, chosen by nobody, paid for every turn.
|
||||
#
|
||||
# It emitted nothing until 2026-09-03, which made the resident set's cost
|
||||
# certain and its usefulness unfalsifiable at the same time (#3473). Note
|
||||
# #3089 is the argument this measurement finally lets someone test: that a
|
||||
# rule arriving with thirty others, none of them relevant, is read as
|
||||
# preamble rather than as a claim — so presence is not surfacing, and a
|
||||
# tier-1 set can grow without anybody noticing it stopped working.
|
||||
#
|
||||
# Recorded even when the hook truncates the block below: the rules WERE
|
||||
# delivered, and counting only the untruncated ones would quietly shrink
|
||||
# the denominator exactly where the set is too big to read.
|
||||
record_rule_surfaced(
|
||||
user_id=user_id,
|
||||
rule_ids=[r.id for r in rules],
|
||||
source="session_start",
|
||||
)
|
||||
excluded = (
|
||||
await rulebooks_svc.excluded_always_on_rulebooks(user_id, project_id)
|
||||
if project_id else []
|
||||
|
||||
@@ -27,6 +27,10 @@ from scribe.models import async_session
|
||||
from scribe.models.base import iso
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
|
||||
from scribe.models.rule_usage import PULLED as RULE_PULLED
|
||||
from scribe.models.rule_usage import SURFACED as RULE_SURFACED
|
||||
from scribe.models.rule_usage import RuleUsageEvent
|
||||
from scribe.services.rule_usage import is_ambient
|
||||
from scribe.models.retrieval_log import RetrievalLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -51,12 +55,26 @@ def _build_payload(
|
||||
is_task: bool | None,
|
||||
results: list[tuple[float, Note]],
|
||||
duration_ms: float | None,
|
||||
suppressed: int | None = None,
|
||||
best_available: float | None = None,
|
||||
) -> dict:
|
||||
"""Reduce a retrieval call to a flat, JSON-safe RetrievalLog payload.
|
||||
|
||||
Pure and synchronous (no DB, no event loop) so it is unit-testable and safe
|
||||
to run inline before scheduling the write. `results` is the
|
||||
`(score, Note)` list from semantic_search_notes, already highest-first.
|
||||
|
||||
`suppressed` is how many scored hits the caller dropped because the session
|
||||
had already been shown them, and it stays None for callers that cannot
|
||||
know. See the column's comment: None means "not measured here", which is a
|
||||
different fact from 0 and must never render as one.
|
||||
|
||||
`best_available` is the highest score the ranker reached BEFORE the
|
||||
threshold, and it carries the same null discipline for a sharper reason: it
|
||||
is the only field that still says something on a call that returned
|
||||
nothing, so a 0.0 standing in for "not measured" would read as "the corpus
|
||||
held nothing remotely relevant" — a claim about the corpus invented out of
|
||||
a caller's silence.
|
||||
"""
|
||||
items = [
|
||||
{"id": int(note.id), "score": round(float(score), 5), "rank": rank}
|
||||
@@ -72,8 +90,12 @@ def _build_payload(
|
||||
"project_id": project_id,
|
||||
"is_task": is_task,
|
||||
"result_count": len(items),
|
||||
"suppressed_count": (None if suppressed is None else int(suppressed)),
|
||||
"top_score": (scores[0] if scores else None),
|
||||
"min_score": (scores[-1] if scores else None),
|
||||
"best_available_score": (
|
||||
None if best_available is None else round(float(best_available), 5)
|
||||
),
|
||||
"result_ids": items,
|
||||
"duration_ms": (round(duration_ms, 2) if duration_ms is not None else None),
|
||||
}
|
||||
@@ -111,6 +133,8 @@ def record_retrieval(
|
||||
is_task: bool | None,
|
||||
results: list[tuple[float, Any]],
|
||||
duration_ms: float | None = None,
|
||||
suppressed: int | None = None,
|
||||
best_available: float | None = None,
|
||||
) -> None:
|
||||
"""Fire-and-forget: record one retrieval call.
|
||||
|
||||
@@ -136,6 +160,8 @@ def record_retrieval(
|
||||
is_task=is_task,
|
||||
results=results,
|
||||
duration_ms=duration_ms,
|
||||
suppressed=suppressed,
|
||||
best_available=best_available,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("retrieval telemetry payload build failed", exc_info=True)
|
||||
@@ -162,36 +188,145 @@ def record_retrieval(
|
||||
|
||||
def _bucket(rows: list) -> dict:
|
||||
"""A score readout a human can act on, from one aggregate row."""
|
||||
calls, zero, cleared, p10, p50, p90, lo, hi, avg_n, dur = rows
|
||||
(calls, zero, p10, p50, p90, lo, hi, avg_n, dur,
|
||||
measured, supp_calls, supp_zero,
|
||||
miss_calls, miss_p50, miss_p90, miss_max) = rows
|
||||
return {
|
||||
"calls": int(calls or 0),
|
||||
# A call that returned nothing is not a low-scoring call — it is a
|
||||
# different failure (nothing indexed, filter too narrow), and averaging
|
||||
# it into the score distribution would hide both.
|
||||
"zero_result_calls": int(zero or 0),
|
||||
# How often the best hit actually cleared the threshold in force for
|
||||
# that call. THE precision-adjacent number: a surface that clears its
|
||||
# bar on almost every call is either well-tuned or too loose, and the
|
||||
# score spread below says which.
|
||||
"cleared_threshold": int(cleared or 0),
|
||||
# `cleared_threshold` USED TO LIVE HERE and it was a tautology (#3670).
|
||||
# The search applies the bar before returning, so every returned result
|
||||
# cleared it by construction and a call with nothing has no score to
|
||||
# compare — the condition was true exactly when `result_count > 0`.
|
||||
# `zero_result_calls + cleared_threshold == calls` held on all nineteen
|
||||
# readings ever taken. It was `calls - zero_result_calls` wearing a name
|
||||
# that promised a second opinion, and the docstring built a reading
|
||||
# procedure on it that asked the reader to compare a number with itself.
|
||||
# Its replacement is `near_misses` below, which the bar cannot fix by
|
||||
# construction because it is measured on the calls the bar REJECTED.
|
||||
# Of the zeros above, which were the RANKER declining and which were
|
||||
# the reader having seen it already? `zero_result_calls` cannot say,
|
||||
# and only the first kind is evidence about the threshold.
|
||||
#
|
||||
# None — not a zeroed dict — when no row in the window reported it. A
|
||||
# surface that filters inside the search genuinely does not know, and
|
||||
# rendering that as `{"calls": 0}` would state a measurement nobody
|
||||
# made. That substitution is the whole of #3311.
|
||||
"suppression": (
|
||||
None if not int(measured or 0) else {
|
||||
"measured_calls": int(measured or 0),
|
||||
"calls_with_suppression": int(supp_calls or 0),
|
||||
# Subtract from zero_result_calls for the true ranker declines.
|
||||
"zero_because_already_shown": int(supp_zero or 0),
|
||||
}
|
||||
),
|
||||
"top_score": {
|
||||
"p10": _round(p10), "p50": _round(p50), "p90": _round(p90),
|
||||
"min": _round(lo), "max": _round(hi),
|
||||
},
|
||||
# WHAT THE BAR TURNED AWAY, and the only figure here a threshold can
|
||||
# actually be tuned from. Measured over the calls that returned
|
||||
# NOTHING, on the best score the ranker reached before the filter.
|
||||
#
|
||||
# Read `p90` against the threshold in force. A bar at 0.72 rejecting a
|
||||
# stream of 0.71s is set too high by a hair and the surface is losing
|
||||
# hits it should have had; the same bar rejecting 0.30s is doing its
|
||||
# job and the corpus simply had nothing. Both render as a zero-result
|
||||
# call, and nothing else in this readout separates them.
|
||||
#
|
||||
# None — not a zeroed block — when no declining call in the window
|
||||
# measured it. Old rows predate the column, and a 0.0 would assert that
|
||||
# the corpus held nothing relevant, which is a claim about the corpus
|
||||
# invented out of a caller's silence.
|
||||
"near_misses": (
|
||||
None if not int(miss_calls or 0) else {
|
||||
"measured_calls": int(miss_calls or 0),
|
||||
"p50": _round(miss_p50),
|
||||
"p90": _round(miss_p90),
|
||||
"max": _round(miss_max),
|
||||
}
|
||||
),
|
||||
"avg_result_count": _round(avg_n),
|
||||
"p90_duration_ms": _round(dur, 1),
|
||||
}
|
||||
|
||||
|
||||
# The aggregate row Postgres would have returned for a source with no rows in
|
||||
# the window: nothing counted, nothing scored. Positional, matching the SELECT
|
||||
# `_bucket` unpacks — calls, zero, p10, p50, p90, min, max, avg_n,
|
||||
# dur, measured, supp_calls, supp_zero, miss_calls, miss_p50, miss_p90,
|
||||
# miss_max. The counts are 0 because zero calls is a real observation;
|
||||
# everything else is None because a distribution nobody sampled has no value,
|
||||
# and rendering it as 0.0 would state one.
|
||||
_NO_ROWS_IN_WINDOW = [0, 0, None, None, None, None, None, None, None,
|
||||
0, 0, 0, 0, None, None, None]
|
||||
|
||||
|
||||
def _round(v, places: int = 4):
|
||||
return None if v is None else round(float(v), places)
|
||||
|
||||
|
||||
async def _complete_from(session, model, user_id) -> dict[str, Any]:
|
||||
"""When each source in `model` started being recorded, and the instant the
|
||||
WHOLE table is complete from. Returns {source: earliest_row, "*": latest}.
|
||||
|
||||
THE GRAIN IS THE SOURCE, and that is the whole point. `retrieval_logs` has
|
||||
rows going back months, so a table-level "earliest row" says months and
|
||||
tells a reader their window is fully covered — while a source added last
|
||||
week has a week of rows and a counter that silently means something else.
|
||||
Per-source is the only grain at which partial coverage is visible.
|
||||
|
||||
THE AGGREGATE USES THE LATEST, NOT THE EARLIEST. A number that sums several
|
||||
sources is complete only once EVERY contributor was recording, so "*" is a
|
||||
max over the sources, not a min. Taking the min here would reproduce the
|
||||
exact reading this exists to prevent: the oldest source vouching for the
|
||||
youngest.
|
||||
|
||||
All-time, deliberately unfiltered by the window — a query bounded by
|
||||
`since` can only ever report something at or after `since`, which answers
|
||||
nothing.
|
||||
"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(model.source, func.min(model.created_at))
|
||||
.where(model.user_id == user_id)
|
||||
.group_by(model.source)
|
||||
)
|
||||
).all()
|
||||
out: dict[str, Any] = {src: ts for src, ts in rows if ts is not None}
|
||||
stamps = list(out.values())
|
||||
out["*"] = max(stamps) if stamps else None
|
||||
return out
|
||||
|
||||
|
||||
def _coverage(complete_from, since) -> dict:
|
||||
"""The two keys every counter block carries, from one timestamp.
|
||||
|
||||
`covers_window` is None — never False — when nothing was ever recorded.
|
||||
"No rows at all" is not "partial coverage", it is no measurement, and the
|
||||
null convention #3497 established for `suppression` holds here for the
|
||||
same reason: absent must not read as a verdict.
|
||||
"""
|
||||
return {
|
||||
# iso() already returns None for an unset value (#2845) — the guard
|
||||
# belongs on covers_window, which is a verdict, not a serialisation.
|
||||
"complete_from": iso(complete_from),
|
||||
"covers_window": (
|
||||
None if complete_from is None else complete_from <= since
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
||||
"""What the retrieval telemetry says, per surface, over a window.
|
||||
|
||||
Two aggregates side by side, each read from the table built for it — NOT a
|
||||
join. `NoteUsageEvent`'s own docstring is explicit that the two are
|
||||
Three aggregates side by side, each read from the table built for it — NOT
|
||||
a join. `usage` is notes, `rule_usage` is rules, and they stay apart
|
||||
because a few dozen eligible rules blended into thousands of notes is the
|
||||
note ratio with noise on it (milestone 333). `NoteUsageEvent`'s own docstring is explicit that the two are
|
||||
complements ("RetrievalLog tunes the threshold, this tunes the corpus") and
|
||||
that RetrievalLog's JSONB `result_ids` "can't be indexed at" the per-note
|
||||
grain. So the score distribution comes from `retrieval_logs` on its indexed
|
||||
@@ -199,6 +334,12 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
||||
it was built for. Reading each from its own table is both cheaper and more
|
||||
honest than correlating them through JSONB.
|
||||
|
||||
`usage["by_source"]` is the one join, and it stays INSIDE
|
||||
`note_usage_events` — surfaced rows against pulled rows on note_id. That
|
||||
answers "of the notes this surface chose, how many were opened", which the
|
||||
top-level ratio averages away. It does not cross into `retrieval_logs`, so
|
||||
the sentence above still holds.
|
||||
|
||||
Scoped to one user's own telemetry. There is no sharing model for a
|
||||
retrieval log — it records what THIS user's agent asked for, including the
|
||||
query text — so an owner filter is the whole access rule here rather than a
|
||||
@@ -214,23 +355,77 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
||||
"since": iso(since),
|
||||
"sources": {},
|
||||
"usage": {},
|
||||
"rule_usage": {},
|
||||
"read_failed": False,
|
||||
}
|
||||
|
||||
cleared = case(
|
||||
(
|
||||
(RetrievalLog.threshold.isnot(None))
|
||||
& (RetrievalLog.top_score.isnot(None))
|
||||
& (RetrievalLog.top_score >= RetrievalLog.threshold),
|
||||
1,
|
||||
),
|
||||
zero = case((RetrievalLog.result_count == 0, 1), else_=0)
|
||||
# THE NEAR-MISS POPULATION: calls that returned nothing BECAUSE THE BAR
|
||||
# TURNED SOMETHING AWAY, and recorded what it was. Three conditions, and
|
||||
# the third was missing for one deploy (#3739).
|
||||
#
|
||||
# Zero-result only: on a call that returned something,
|
||||
# `best_available_score` equals `top_score` and adds nothing.
|
||||
#
|
||||
# Non-null only: rows written before #3670 genuinely do not know, and must
|
||||
# not read as scoreless declines.
|
||||
#
|
||||
# AND NOT A REPEAT. A zero-result call is two unrelated events — the ranker
|
||||
# found nothing above the bar, or it found only what this session had
|
||||
# already been shown — and just the first says anything about the bar. That
|
||||
# is the whole of #3497, and #3670 reintroduced the conflation one level up:
|
||||
# the rule arms filter exclusions in PYTHON, after the search, so a rule
|
||||
# that cleared the bar and was dropped as a repeat still reported a high
|
||||
# `best_available_score` on a zero-result row. Live proof, first read after
|
||||
# deploy: pre_tool_rule's near-miss max was 0.7457 while the lowest score it
|
||||
# ever RETURNED was 0.7204 — a "rejection" that outscored acceptances.
|
||||
#
|
||||
# The NULL arm is principled, not permissive: `suppressed_count IS NULL`
|
||||
# means the caller passed its exclusions INTO the search, which is exactly
|
||||
# the case where the reported score is already post-exclusion and cannot be
|
||||
# contaminated. Note arms stay measured; rule arms get cleaned.
|
||||
#
|
||||
# Deliberately conservative: a call carrying both a repeat and a lower
|
||||
# genuine miss is dropped whole, losing that point. It undercounts; it
|
||||
# cannot corrupt — the right way round for a number read against a bar.
|
||||
#
|
||||
# This also makes `near_misses.max < threshold` true BY CONSTRUCTION. An
|
||||
# above-bar candidate that was not excluded would have been returned, so
|
||||
# its call is not in this population at all.
|
||||
declined = (
|
||||
(RetrievalLog.result_count == 0)
|
||||
& (RetrievalLog.best_available_score.isnot(None))
|
||||
& (
|
||||
RetrievalLog.suppressed_count.is_(None)
|
||||
| (RetrievalLog.suppressed_count == 0)
|
||||
)
|
||||
)
|
||||
miss = case((declined, 1), else_=0)
|
||||
# `best_available_score` only for those rows; NULL elsewhere, and
|
||||
# percentile_cont ignores NULLs, so the distribution is over the declines
|
||||
# alone without a second pass over the table.
|
||||
miss_score = case((declined, RetrievalLog.best_available_score), else_=None)
|
||||
# Three sums rather than one, because "not measured" and "measured as zero"
|
||||
# are different answers and a single counter cannot hold both.
|
||||
measured = case((RetrievalLog.suppressed_count.isnot(None), 1), else_=0)
|
||||
supp_calls = case((RetrievalLog.suppressed_count > 0, 1), else_=0)
|
||||
supp_zero = case(
|
||||
((RetrievalLog.result_count == 0) & (RetrievalLog.suppressed_count > 0), 1),
|
||||
else_=0,
|
||||
)
|
||||
zero = case((RetrievalLog.result_count == 0, 1), else_=0)
|
||||
|
||||
def pct(p: float):
|
||||
return func.percentile_cont(p).within_group(RetrievalLog.top_score.asc())
|
||||
|
||||
# Assigned inside the try below; named here so the readout can tell
|
||||
# "this query failed" from "this window has no rows" (#2663).
|
||||
by_source_rows = None
|
||||
rule_rows = None
|
||||
distinct_rules_surfaced = distinct_rules_pulled = 0
|
||||
# None means the coverage read did not happen — distinct from a table with
|
||||
# no rows, which is {"*": None}. Same reason `read_failed` exists.
|
||||
note_complete = rule_complete = None
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
@@ -239,7 +434,6 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
||||
RetrievalLog.source,
|
||||
func.count().label("calls"),
|
||||
func.sum(zero).label("zero"),
|
||||
func.sum(cleared).label("cleared"),
|
||||
pct(0.1), pct(0.5), pct(0.9),
|
||||
func.min(RetrievalLog.top_score),
|
||||
func.max(RetrievalLog.top_score),
|
||||
@@ -247,6 +441,13 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
||||
func.percentile_cont(0.9).within_group(
|
||||
RetrievalLog.duration_ms.asc()
|
||||
),
|
||||
func.sum(measured).label("measured"),
|
||||
func.sum(supp_calls).label("supp_calls"),
|
||||
func.sum(supp_zero).label("supp_zero"),
|
||||
func.sum(miss).label("miss_calls"),
|
||||
func.percentile_cont(0.5).within_group(miss_score.asc()),
|
||||
func.percentile_cont(0.9).within_group(miss_score.asc()),
|
||||
func.max(miss_score),
|
||||
)
|
||||
.where(
|
||||
RetrievalLog.created_at >= since,
|
||||
@@ -255,8 +456,34 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
||||
.group_by(RetrievalLog.source)
|
||||
)
|
||||
).all()
|
||||
log_complete = await _complete_from(session, RetrievalLog, user_id)
|
||||
for row in rows:
|
||||
out["sources"][row[0]] = _bucket(list(row[1:]))
|
||||
source = row[0]
|
||||
bucket = _bucket(list(row[1:]))
|
||||
# Per SOURCE, not per table: retrieval_logs goes back months
|
||||
# while any individual arm may be days old, and the table's
|
||||
# age would vouch for an arm that has barely started.
|
||||
bucket.update(_coverage(log_complete.get(source), since))
|
||||
out["sources"][source] = bucket
|
||||
|
||||
# A source with rows in the table but NONE in this window would
|
||||
# otherwise be absent from the readout — and absent is exactly how
|
||||
# a source that never existed renders, so a surface that WAS
|
||||
# recording and went silent is unreadable (#3720). That is #2663
|
||||
# one level up: the failure that looks like the correct answer.
|
||||
#
|
||||
# Zero here is a real measurement, not a manufactured one. The
|
||||
# all-time query proves the source was recording, and it made no
|
||||
# calls across a window it fully covers — which is why no
|
||||
# `covers_window` special case is needed: a source whose first row
|
||||
# fell after `since` would have that row IN the window and already
|
||||
# hold a bucket, so anything reaching here began before it.
|
||||
for src, first_row in log_complete.items():
|
||||
if src == "*" or first_row is None or src in out["sources"]:
|
||||
continue
|
||||
quiet = _bucket(list(_NO_ROWS_IN_WINDOW))
|
||||
quiet.update(_coverage(first_row, since))
|
||||
out["sources"][src] = quiet
|
||||
|
||||
# The corpus side, at its own grain. `ambient` mirrors
|
||||
# note_usage.usage_for_notes: an ambient surfacing was not a scored
|
||||
@@ -283,6 +510,7 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
||||
.group_by(NoteUsageEvent.event, NoteUsageEvent.source)
|
||||
)
|
||||
).all()
|
||||
note_complete = await _complete_from(session, NoteUsageEvent, user_id)
|
||||
|
||||
# Distinct-note counts need their OWN queries, and this is not
|
||||
# fussiness: count(distinct note_id) per (event, source) group
|
||||
@@ -310,6 +538,142 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
# Per-source pull-through, at the NOTE grain (#3311).
|
||||
#
|
||||
# The `urows` query above already groups by source and the loop
|
||||
# below then throws the source away, so until now this readout
|
||||
# could say what the corpus's overall pull-through was and nothing
|
||||
# about WHICH surface earned it. The data was always here; only
|
||||
# the aggregation discarded it.
|
||||
#
|
||||
# It cannot be had by grouping the PULLED rows by source: a pull
|
||||
# records the door it came through (`mcp_get_note`), not the
|
||||
# surface that put the record in front of the agent. Correlating
|
||||
# those within a session is what #2085 ruled out — there is no
|
||||
# session identity server-side and inventing one would mean
|
||||
# threading a client-supplied token through every read path. The
|
||||
# note grain answers the question without one: of the distinct
|
||||
# notes surface X chose, how many did an agent open in this window?
|
||||
#
|
||||
# Guarded separately from the reads above, on #2663's actual
|
||||
# lesson. That outage was a NOVEL SQL SHAPE the database rejected
|
||||
# inside a broad except. This join is the novel shape here, and a
|
||||
# failure in it must not take down two readouts that already work.
|
||||
try:
|
||||
pulled_ids = (
|
||||
select(NoteUsageEvent.note_id)
|
||||
.where(
|
||||
NoteUsageEvent.created_at >= since,
|
||||
NoteUsageEvent.user_id == user_id,
|
||||
NoteUsageEvent.event == PULLED,
|
||||
# autoescape because `_` is a LIKE wildcard: a bare
|
||||
# like("mcp_%") also matches "mcpX…". The Python half
|
||||
# of this readout uses str.startswith and has no such
|
||||
# hazard; this is the SQL half's version of it.
|
||||
NoteUsageEvent.source.startswith("mcp_", autoescape=True),
|
||||
)
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
surfaced_pairs = (
|
||||
select(NoteUsageEvent.source, NoteUsageEvent.note_id)
|
||||
.where(
|
||||
NoteUsageEvent.created_at >= since,
|
||||
NoteUsageEvent.user_id == user_id,
|
||||
NoteUsageEvent.event == SURFACED,
|
||||
)
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
# DISTINCT on (source, note_id) FIRST, which is what lets the
|
||||
# outer aggregate be a plain count(): the pairs are already
|
||||
# unique, so the left join cannot multiply them and no
|
||||
# count(DISTINCT) is needed to undo damage that never happens.
|
||||
by_source_rows = (
|
||||
await session.execute(
|
||||
select(
|
||||
surfaced_pairs.c.source,
|
||||
func.count().label("notes_surfaced"),
|
||||
func.count(pulled_ids.c.note_id).label("notes_pulled"),
|
||||
)
|
||||
.select_from(
|
||||
surfaced_pairs.outerjoin(
|
||||
pulled_ids,
|
||||
pulled_ids.c.note_id == surfaced_pairs.c.note_id,
|
||||
)
|
||||
)
|
||||
.group_by(surfaced_pairs.c.source)
|
||||
)
|
||||
).all()
|
||||
except Exception:
|
||||
logger.warning("per-source pull-through read failed", exc_info=True)
|
||||
by_source_rows = None
|
||||
|
||||
# Rules, at their own grain and in their own block (milestone 333).
|
||||
#
|
||||
# Guarded separately from the reads above for the reason `by_source`
|
||||
# is: this table is NEW, and an instance running upgraded code
|
||||
# against un-migrated schema would otherwise take down two readouts
|
||||
# that work perfectly in order to report a third that cannot.
|
||||
#
|
||||
# The queries themselves are the note block's shapes, not novel
|
||||
# ones — a group-by on two indexed columns and two count(distinct).
|
||||
# The distinct counts need their own queries for the same reason
|
||||
# the note ones do: count(distinct rule_id) per group cannot be
|
||||
# summed across groups without double-counting a rule two sources
|
||||
# both touched.
|
||||
try:
|
||||
rule_rows = (
|
||||
await session.execute(
|
||||
select(
|
||||
RuleUsageEvent.event,
|
||||
RuleUsageEvent.source,
|
||||
func.count().label("n"),
|
||||
)
|
||||
.where(
|
||||
RuleUsageEvent.created_at >= since,
|
||||
RuleUsageEvent.user_id == user_id,
|
||||
)
|
||||
.group_by(RuleUsageEvent.event, RuleUsageEvent.source)
|
||||
)
|
||||
).all()
|
||||
rule_complete = await _complete_from(
|
||||
session, RuleUsageEvent, user_id,
|
||||
)
|
||||
# The rows carry `source`, so the ranked/ambient split is done
|
||||
# below rather than in SQL — the bulk surfaces started emitting
|
||||
# on 2026-09-03 (#3473), so there IS an ambient class now.
|
||||
#
|
||||
# `distinct_rules_surfaced` deliberately counts BOTH classes. It
|
||||
# answers "how many distinct rules did this install put in front
|
||||
# of an agent at all", which is the denominator for dead weight
|
||||
# — and a rule delivered by the preload a hundred times and
|
||||
# never opened is the most important case that question has.
|
||||
distinct_rules_surfaced = (
|
||||
await session.execute(
|
||||
select(func.count(func.distinct(RuleUsageEvent.rule_id)))
|
||||
.where(
|
||||
RuleUsageEvent.created_at >= since,
|
||||
RuleUsageEvent.user_id == user_id,
|
||||
RuleUsageEvent.event == RULE_SURFACED,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
distinct_rules_pulled = (
|
||||
await session.execute(
|
||||
select(func.count(func.distinct(RuleUsageEvent.rule_id)))
|
||||
.where(
|
||||
RuleUsageEvent.created_at >= since,
|
||||
RuleUsageEvent.user_id == user_id,
|
||||
RuleUsageEvent.event == RULE_PULLED,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
except Exception:
|
||||
logger.warning("rule usage read failed", exc_info=True)
|
||||
rule_rows = None
|
||||
distinct_rules_surfaced = distinct_rules_pulled = 0
|
||||
except Exception:
|
||||
logger.warning("retrieval summary read failed", exc_info=True)
|
||||
out["read_failed"] = True
|
||||
@@ -350,5 +714,124 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
||||
round(usage["pulled_by_agent"] / usage["surfaced"], 4)
|
||||
if usage["surfaced"] else None
|
||||
)
|
||||
|
||||
# The same question, per surface — which is the one the top-level ratio
|
||||
# cannot answer. A corpus average of 0.05 is compatible with one surface
|
||||
# earning its noise and another producing none, and tuning a threshold
|
||||
# needs to know which.
|
||||
#
|
||||
# UPPER BOUND, and say so where it will be read: a pull records the door,
|
||||
# not the surface that led to it, so a note surfaced by two surfaces and
|
||||
# opened once counts as pulled for both. Attribution would need the session
|
||||
# identity #2085 declined to invent. The bound is still decisive in the
|
||||
# direction that matters — a surface reading near zero here is not being
|
||||
# flattered by the double-count.
|
||||
if by_source_rows is None:
|
||||
usage["by_source"] = {}
|
||||
# Distinct from an empty window, for the same reason `read_failed` is.
|
||||
usage["by_source_failed"] = True
|
||||
else:
|
||||
by_source: dict[str, dict] = {}
|
||||
for source, n_surfaced, n_pulled in by_source_rows:
|
||||
n_surfaced, n_pulled = int(n_surfaced or 0), int(n_pulled or 0)
|
||||
ambient = source in AMBIENT_SOURCES
|
||||
by_source[source] = {
|
||||
"notes_surfaced": n_surfaced,
|
||||
"notes_pulled": n_pulled,
|
||||
# None rather than a number on an ambient surface: nothing
|
||||
# CHOSE those records, so "surfaced often, opened never" is not
|
||||
# a judgment about them. The counts stay visible; the ratio
|
||||
# that would be misread does not.
|
||||
"pull_through": (
|
||||
None if ambient or not n_surfaced
|
||||
else round(n_pulled / n_surfaced, 4)
|
||||
),
|
||||
"ambient": ambient,
|
||||
}
|
||||
usage["by_source"] = by_source
|
||||
|
||||
# The SECTION's coverage, from the latest source to start recording — a
|
||||
# figure that sums several sources is complete only once every one of them
|
||||
# was being written. `_complete_from` computes that as "*".
|
||||
usage.update(_coverage((note_complete or {}).get("*"), since))
|
||||
out["usage"] = usage
|
||||
|
||||
# ── Rules, deliberately a SEPARATE block ────────────────────────────
|
||||
#
|
||||
# Not folded into `usage`, for two reasons and the second is the one that
|
||||
# bites. The corpora differ by orders of magnitude — a few dozen eligible
|
||||
# rules against thousands of notes — so one blended ratio would be the note
|
||||
# ratio with a little noise on it, and the rule arm's own behaviour would
|
||||
# be undetectable inside it. And `usage` is what existing callers already
|
||||
# read: silently changing what it counts would move a number people have
|
||||
# been comparing across windows, without telling them it now measures
|
||||
# something else.
|
||||
#
|
||||
# `ambient` now carries the bulk deliveries — the SessionStart preload,
|
||||
# `list_always_on_rules`, and every `rules_payload` surface (#3473). Before
|
||||
# they emitted, this block had no ambient key and said the absence was a
|
||||
# fact about the data. It was, and it was also the thing that made the
|
||||
# always-on set impossible to judge: the largest rule surface in the
|
||||
# product was the one surface its own scoreboard could not see.
|
||||
#
|
||||
# READ THE TWO SEPARATELY, ALWAYS. `surfaced` is a claim a ranker made and
|
||||
# a pull can settle. `ambient` is a delivery nobody chose, so a high count
|
||||
# says the set is large and resident, never that it is useful.
|
||||
rule_usage = {
|
||||
"surfaced": 0, "ambient": 0,
|
||||
"pulled": 0, "pulled_by_agent": 0, "pulled_by_human": 0,
|
||||
"distinct_rules_surfaced": int(distinct_rules_surfaced or 0),
|
||||
"distinct_rules_pulled": int(distinct_rules_pulled or 0),
|
||||
}
|
||||
if rule_rows is None:
|
||||
# The FLAG is added, the shape is kept — matching `by_source_failed`
|
||||
# one block up. A caller that renders this must not have to choose
|
||||
# between crashing on a missing key and quietly showing zeros it has no
|
||||
# right to: the keys let it render, and the flag tells it the zeros are
|
||||
# "we could not find out" rather than "nothing happened" (#2663).
|
||||
rule_usage["rule_usage_failed"] = True
|
||||
else:
|
||||
for event, source, n in rule_rows:
|
||||
n = int(n)
|
||||
if event == RULE_SURFACED:
|
||||
# One definition of ranked-vs-ambient, imported rather than
|
||||
# restated — the per-rule badge readout reads the same
|
||||
# predicate, and two spellings of "what counts as surfaced" is
|
||||
# precisely the uneven wiring #3246 found across this system.
|
||||
if is_ambient(source):
|
||||
rule_usage["ambient"] += n
|
||||
else:
|
||||
rule_usage["surfaced"] += n
|
||||
elif event == RULE_PULLED:
|
||||
rule_usage["pulled"] += n
|
||||
# Same split, and it carries MORE weight here than for notes.
|
||||
# The arm's whole claim is "this rule may apply to what you are
|
||||
# writing", and only an agent opening it says the claim landed.
|
||||
# A person browsing the rule list says nothing about the hint.
|
||||
if source.startswith("mcp_"):
|
||||
rule_usage["pulled_by_agent"] += n
|
||||
else:
|
||||
rule_usage["pulled_by_human"] += n
|
||||
|
||||
# None, not 0.0, when nothing was surfaced — matching the note block. A
|
||||
# ratio of zero asserts "we showed rules and none were opened"; with an
|
||||
# empty numerator AND denominator that is a claim the data does not
|
||||
# support, and it is the reading that would make a brand-new install look
|
||||
# like a broken one.
|
||||
#
|
||||
# RANKED SURFACINGS ONLY in the denominator, and this is the load-bearing
|
||||
# line of the whole change. Pull-through asks "was that hint any use", and
|
||||
# only a surface that CHOSE what it showed can be judged by it. Folding the
|
||||
# preload in would divide the same pulls by a number that grows with every
|
||||
# session and every rule added to the resident set — so enlarging the
|
||||
# always-on set would DEPRESS the arm's measured precision, and trimming it
|
||||
# would flatter it, neither for any reason to do with the arm. The ambient
|
||||
# count sits beside it, unaveraged, and is read as size rather than skill.
|
||||
rule_usage["pull_through"] = (
|
||||
round(rule_usage["pulled_by_agent"] / rule_usage["surfaced"], 4)
|
||||
if rule_usage["surfaced"] else None
|
||||
)
|
||||
rule_usage.update(_coverage((rule_complete or {}).get("*"), since))
|
||||
out["rule_usage"] = rule_usage
|
||||
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Rule usage telemetry — did a surfaced rule ever get read?
|
||||
|
||||
The sibling of `note_usage`, for the one retrieval surface in Scribe that
|
||||
could not be measured at all.
|
||||
|
||||
Two event streams, deliberately independent:
|
||||
|
||||
- SURFACED: the write-path standing-rule arm put this rule in front of the
|
||||
agent, unbidden, during a write.
|
||||
- PULLED: someone then opened it in full (`get_rule`, or the REST detail
|
||||
route).
|
||||
|
||||
WHY THIS ARM AND NOT ANOTHER. Every other surface declines most of the time —
|
||||
`write_path` returns nothing on 78% of calls, `reuse_slot` on 79%, auto-inject
|
||||
on 39%. The rule arm APPEARED never to have returned nothing (#3311), and this
|
||||
docstring used to put that forward as the puzzle worth measuring: "either a
|
||||
perfectly tuned surface or a bar it cannot fail to clear".
|
||||
|
||||
It was neither, and the correction belongs here rather than being quietly
|
||||
deleted. The arm wrote its `retrieval_logs` row only on calls that FOUND
|
||||
something (#3497), so `zero_result_calls` sat at 0 and `cleared_threshold` at
|
||||
`calls` because of the shape of the code — at any threshold whatsoever. A
|
||||
statistic that could not vary was read as a finding about the corpus. It is the
|
||||
#2663 failure mode one level up: there the broken readout was a zero, here it
|
||||
was a hundred percent, which is far better camouflage.
|
||||
|
||||
The reason to measure this arm survives the correction, and is stronger for it.
|
||||
`retrieval_logs` records what the ranker scored, never whether the hint was any
|
||||
use, so even an honest clear-rate would not settle the question. The ratio these
|
||||
two streams produce is the missing half, and without it any threshold change is
|
||||
a number picked off a histogram.
|
||||
|
||||
Design notes, mirroring `note_usage`:
|
||||
- Writes are fire-and-forget through `background.spawn`, so telemetry never
|
||||
adds latency to — or can break — the surface it observes. This module does
|
||||
NOT carry its own copy of the strong-reference dance; `background` is the
|
||||
one place that gets it right, and a fourth copy is how one of them drifts.
|
||||
- Failures degrade, but never SILENTLY. `report_telemetry_failure` logs at
|
||||
WARNING and drops one AppLog row per process per site. #2663 is the record
|
||||
of this exact subsystem class running at zero for weeks — indistinguishable
|
||||
from "nobody uses this" — because every failure went to `logger.debug`.
|
||||
- Reads (`usage_for_rules`) are awaited and aggregated in one round-trip for
|
||||
a whole page, never per row.
|
||||
|
||||
AMBIENT VS RANKED. The note twin splits ranked surfacings from ambient ones
|
||||
because `enter_project` and the skill sync put records in front of the agent
|
||||
without choosing them, and counting those as surfacings makes recency read as
|
||||
popularity (#2477). Rules have exactly that shape: the SessionStart preload,
|
||||
`list_always_on_rules`, and every `rules_payload` surface hand over the whole
|
||||
applicable set at once, chosen by nobody.
|
||||
|
||||
Until 2026-09-03 those bulk surfaces emitted nothing, and this module said so —
|
||||
"an empty `AMBIENT_SOURCES` would be machinery pretending to a distinction the
|
||||
data does not yet contain". True as far as it went, but it had a consequence
|
||||
worth naming, because it is the reason the bucket exists now: the always-on
|
||||
set's token cost was certain and its usefulness was UNFALSIFIABLE, permanently
|
||||
and by construction. The one surface whose value was actually in question was
|
||||
the one surface exempt from the scoreboard that judges every other.
|
||||
|
||||
They emit now. The split is the readout-level change the old note promised — a
|
||||
`case()`, no migration, because `event` and `source` are plain Text with no
|
||||
CHECK constraint. `source` stays granular so a reader can still tell the
|
||||
preload from `enter_project` from the ranked arm.
|
||||
|
||||
WHY THIS NAMES THE RANKED SOURCES AND THE TWIN NAMES THE AMBIENT ONES. A
|
||||
deliberate divergence, on the failure mode rather than on symmetry. Both shapes
|
||||
fail silently when someone adds a surface and forgets the list, so the question
|
||||
is which list changes more often — and here it is emphatically the ambient one:
|
||||
there are TWO ranked rule sources (the write-path arm and the pre-tool arm)
|
||||
against the seven bulk ones the preload alone contributes. Ranked sources are
|
||||
added when somebody builds a ranker, which is rare and deliberate; bulk ones
|
||||
appear whenever a surface hands rules over, which is most of them. Naming the
|
||||
rare, slow-moving half means a newly-added bulk surface defaults to
|
||||
`ambient`, which merely under-counts it, instead of defaulting to `ranked`,
|
||||
which would quietly pad the pull-through denominator with surfacings nobody
|
||||
chose and make the arm look imprecise. Same argument #3191 and #3430 make
|
||||
against hand-kept lists: keep the list that must be remembered as short and as
|
||||
slow-moving as possible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.base import iso
|
||||
from scribe.models.rule_usage import PULLED, SURFACED, RuleUsageEvent
|
||||
from scribe.services.background import report_telemetry_failure, spawn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The surfaces that CHOSE the rules they showed. Everything else is ambient —
|
||||
# see the module docstring for why the rare half is the half that gets named.
|
||||
#
|
||||
# Membership is the whole definition of the pull-through denominator: a ranked
|
||||
# surfacing is a claim ("this rule may apply to what you are doing") that a pull
|
||||
# can confirm or refute, while an ambient one is a delivery nobody decided on.
|
||||
# Add a source here only when a ranker picked it.
|
||||
RANKED_SOURCES = ("write_path_rule", "pre_tool_rule")
|
||||
|
||||
|
||||
def is_ambient(source: str) -> bool:
|
||||
"""Was this surfacing a bulk delivery rather than a ranked choice?
|
||||
|
||||
One definition, read by both the per-rule badge readout and the aggregate
|
||||
in `retrieval_telemetry` — the two used to be able to disagree about what
|
||||
"surfaced" counted, which is the class of drift #3246 found across the
|
||||
rules system.
|
||||
|
||||
Sync and pure, per the service canon (#2860), but deliberately PUBLIC where
|
||||
that canon says such helpers stay `_private`. The departure is the point:
|
||||
a module-private copy in each caller is exactly the second definition this
|
||||
exists to prevent.
|
||||
"""
|
||||
return source not in RANKED_SOURCES
|
||||
|
||||
|
||||
async def _report_failure(site: str) -> None:
|
||||
await report_telemetry_failure("rule_usage", site)
|
||||
|
||||
|
||||
async def _insert_events(rows: list[dict]) -> None:
|
||||
"""Persist usage rows. Best-effort: failures degrade, visibly."""
|
||||
try:
|
||||
async with async_session() as session:
|
||||
session.add_all([RuleUsageEvent(**row) for row in rows])
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await _report_failure("write")
|
||||
|
||||
|
||||
def _schedule(rows: list[dict]) -> None:
|
||||
if not rows:
|
||||
return
|
||||
spawn(_insert_events(rows), site="rule_usage_write")
|
||||
|
||||
|
||||
def record_rule_surfaced(
|
||||
*, user_id: int | None, rule_ids: list[int] | set[int], source: str
|
||||
) -> None:
|
||||
"""Fire-and-forget: record that these rules were shown to the agent.
|
||||
|
||||
Takes the whole delivery at once — one insert per surfacing event, not per
|
||||
rule — because a hint is a single decision and its rows should land
|
||||
together.
|
||||
|
||||
Record what was actually SHOWN, never what was considered. For the ranked
|
||||
arm that means the post-filter hits: it drops what the session already
|
||||
holds (`exclude_rule_ids`) before it speaks, and a rule considered and not
|
||||
shown was not surfaced. Counting those would inflate the denominator with
|
||||
claims the agent never saw, which reads as a precision problem the arm does
|
||||
not have.
|
||||
|
||||
Bulk surfaces pass their whole delivered set, which is the same rule read
|
||||
from the other end — everything in a preload IS shown. `source` is what
|
||||
separates the two afterwards (see `RANKED_SOURCES`); this function does not
|
||||
care which kind it is recording.
|
||||
"""
|
||||
try:
|
||||
rows = [
|
||||
{
|
||||
"user_id": user_id,
|
||||
"rule_id": int(rid),
|
||||
"event": SURFACED,
|
||||
"source": source,
|
||||
}
|
||||
for rid in rule_ids
|
||||
]
|
||||
except Exception:
|
||||
logger.debug("rule usage payload build failed", exc_info=True)
|
||||
return
|
||||
_schedule(rows)
|
||||
|
||||
|
||||
def record_rule_pulled(*, user_id: int | None, rule_id: int, source: str) -> None:
|
||||
"""Fire-and-forget: record that a rule was opened in full.
|
||||
|
||||
A PULL is somebody choosing to open one record. `list_always_on_rules` and
|
||||
`enter_project` are NOT pulls — they are bulk resident loads that hand over
|
||||
every applicable rule at once, and counting them would swamp the signal
|
||||
with the very ambient delivery the ratio exists to distinguish from.
|
||||
"""
|
||||
try:
|
||||
rows = [
|
||||
{
|
||||
"user_id": user_id,
|
||||
"rule_id": int(rule_id),
|
||||
"event": PULLED,
|
||||
"source": source,
|
||||
}
|
||||
]
|
||||
except Exception:
|
||||
logger.debug("rule usage payload build failed", exc_info=True)
|
||||
return
|
||||
_schedule(rows)
|
||||
|
||||
|
||||
def empty_rule_usage() -> dict:
|
||||
"""The zero readout — what a rule with no recorded events looks like.
|
||||
|
||||
Callers render this shape unconditionally, so a rule predating the table
|
||||
reads as "never surfaced, never pulled" rather than as a missing key. That
|
||||
distinction matters more here than for notes: every rule in an install
|
||||
predates this table, so for a while "no events" is the normal state and it
|
||||
must not look like a broken readout.
|
||||
|
||||
`surfaced_count` is RANKED surfacings only; `ambient_count` is the bulk
|
||||
deliveries (see `RANKED_SOURCES`). The split is what keeps the badge's
|
||||
"shown often, opened never → dead weight" reading honest: every rule in an
|
||||
always-on set is delivered every session, so an unsplit counter would rank
|
||||
the resident set as the most-surfaced rules in the install purely for being
|
||||
resident.
|
||||
"""
|
||||
return {
|
||||
"surfaced_count": 0,
|
||||
"ambient_count": 0,
|
||||
"pull_count": 0,
|
||||
"last_surfaced_at": None,
|
||||
"last_pulled_at": None,
|
||||
}
|
||||
|
||||
|
||||
async def usage_for_rules(rule_ids: list[int]) -> dict[int, dict]:
|
||||
"""Aggregate usage for a set of rules: {rule_id: {counts + timestamps}}.
|
||||
|
||||
One GROUP BY for the whole page rather than a query per row — this feeds a
|
||||
list view, so the per-row shape would be N+1 by construction. Rules with no
|
||||
events come back with `empty_rule_usage()`, so the caller never has to tell
|
||||
"no events" from "not in the result".
|
||||
"""
|
||||
ids = [int(r) for r in rule_ids]
|
||||
out: dict[int, dict] = {rid: empty_rule_usage() for rid in ids}
|
||||
if not ids:
|
||||
return out
|
||||
|
||||
# Classified in SQL so the group stays small: per rule we get at most
|
||||
# (surfaced-ranked, surfaced-ambient, pulled) rather than a row per distinct
|
||||
# source. ONE labelled expression, bound to a variable and reused in the
|
||||
# GROUP BY — a second `case()` instance there renders its own expanding-IN
|
||||
# bind names under asyncpg, so the database sees two DIFFERENT expressions
|
||||
# and rejects the query with a GroupingError. The note twin carries the
|
||||
# same warning for the same reason, and #2663 is what it cost: the
|
||||
# rejection was swallowed and every counter read zero in production while
|
||||
# the writes were landing fine.
|
||||
ambient = case(
|
||||
(RuleUsageEvent.source.notin_(RANKED_SOURCES), True),
|
||||
else_=False,
|
||||
).label("ambient")
|
||||
try:
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(
|
||||
RuleUsageEvent.rule_id,
|
||||
RuleUsageEvent.event,
|
||||
func.count().label("n"),
|
||||
func.max(RuleUsageEvent.created_at).label("last_at"),
|
||||
ambient,
|
||||
)
|
||||
.where(RuleUsageEvent.rule_id.in_(ids))
|
||||
.group_by(
|
||||
RuleUsageEvent.rule_id,
|
||||
RuleUsageEvent.event,
|
||||
ambient,
|
||||
)
|
||||
)
|
||||
).all()
|
||||
except Exception:
|
||||
# A telemetry readout must not be able to break the list it decorates —
|
||||
# but it must say it failed, or a broken readout is indistinguishable
|
||||
# from a corpus nobody uses (#2663).
|
||||
await _report_failure("readout")
|
||||
return out
|
||||
|
||||
for rule_id, event, n, last_at, is_amb in rows:
|
||||
slot = out.get(int(rule_id))
|
||||
if slot is None:
|
||||
continue
|
||||
if event == SURFACED and is_amb:
|
||||
slot["ambient_count"] = int(n)
|
||||
elif event == SURFACED:
|
||||
slot["surfaced_count"] = int(n)
|
||||
slot["last_surfaced_at"] = iso(last_at)
|
||||
elif event == PULLED:
|
||||
# Pulls are pulls regardless of what surfaced the rule — "did
|
||||
# anyone ever open this?" does not depend on how it was found. Both
|
||||
# halves accumulate, so this ADDS rather than assigns: a rule can
|
||||
# now be pulled after a ranked hint and after a preload, and the
|
||||
# split arrives as two rows.
|
||||
slot["pull_count"] = slot["pull_count"] + int(n)
|
||||
latest = iso(last_at)
|
||||
if latest and (slot["last_pulled_at"] or "") < latest:
|
||||
slot["last_pulled_at"] = latest
|
||||
return out
|
||||
@@ -23,6 +23,7 @@ from scribe.services.verification import (
|
||||
)
|
||||
from scribe.services import rule_versions
|
||||
from scribe.models.rule_version import RuleVersion
|
||||
from scribe.services.rule_usage import record_rule_surfaced
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -379,6 +380,10 @@ def _refresh_rule_embedding(rule: Rule) -> None:
|
||||
swallowed because a rule that SAVED must not fail on its index refresh —
|
||||
a stale vector costs a missed search hit, a raised exception costs the
|
||||
write. No running loop (unit tests, scripts) is ordinary, not an error.
|
||||
|
||||
Detaching also means this task races anything that deletes the rule out
|
||||
from under it. That is not handled here: `upsert_rule_embedding` claims
|
||||
the rule's row before touching its vectors, and loses if it can't (#3262).
|
||||
"""
|
||||
try:
|
||||
import asyncio
|
||||
@@ -1391,7 +1396,7 @@ async def get_applicable_rules(
|
||||
}
|
||||
|
||||
|
||||
def rules_payload(applicable: dict) -> dict:
|
||||
def rules_payload(applicable: dict, *, user_id: int | None, source: str) -> dict:
|
||||
"""The caller-facing shape of a get_applicable_rules() result.
|
||||
|
||||
Every surface that hands rules to an agent (enter_project, get_project,
|
||||
@@ -1402,7 +1407,34 @@ def rules_payload(applicable: dict) -> dict:
|
||||
`excluded_always_on` (milestone 297) names the always-on rulebooks this
|
||||
project decided NOT to inherit, so the departure is visible wherever the
|
||||
rules are.
|
||||
|
||||
IT ALSO RECORDS THE SURFACING, which is why it now takes a caller and a
|
||||
source. Every one of those surfaces is a bulk delivery — the applicable set
|
||||
handed over whole, chosen by nobody — so this is the one place that has to
|
||||
emit for all of them. Doing it per-caller instead would be five sites to
|
||||
remember, and #3430 gap 2 is what that costs: the process→skill sync went
|
||||
un-emitted through an entire dedicated telemetry survey because nothing
|
||||
forced its surface to be accounted for.
|
||||
|
||||
`source` stays the CALLER's name rather than a constant, so the readout can
|
||||
still separate the session handshake from a mid-session milestone read;
|
||||
`RANKED_SOURCES` in `rule_usage` is what folds them back together.
|
||||
|
||||
Emitting from here is safe in a way emitting from `get_applicable_rules`
|
||||
would not be: this function is only ever called to BUILD A REPLY. The two
|
||||
other callers of the rules machinery — the write-path etag arm
|
||||
(`plugin_context`) and `rules_etag_for` — compute a marker and show nobody
|
||||
anything, and counting those would put rules in the denominator that no
|
||||
agent ever saw.
|
||||
"""
|
||||
record_rule_surfaced(
|
||||
user_id=user_id,
|
||||
rule_ids=(
|
||||
[r["id"] for r in applicable.get("rules", [])]
|
||||
+ [r["id"] for r in applicable.get("project_rules", [])]
|
||||
),
|
||||
source=source,
|
||||
)
|
||||
return {
|
||||
"applicable_rules": applicable["rules"],
|
||||
"applicable_rules_truncated": applicable["truncated"],
|
||||
|
||||
+6
-2
@@ -59,14 +59,18 @@ def tool_doc(module: str, name: str) -> str:
|
||||
return _re.sub(r"\s+", " ", fn.__doc__)
|
||||
|
||||
|
||||
def compiled_sql(element) -> str:
|
||||
def compiled_sql(element, dialect=None) -> str:
|
||||
"""A SQLAlchemy clause or statement rendered as literal SQL text.
|
||||
|
||||
For asserting on the shape of a predicate without a database — which is how
|
||||
the visibility clauses and the knowledge facets are both tested. Was a
|
||||
private copy in each of those modules before #3128 needed a third.
|
||||
|
||||
Pass `dialect` when the assertion is about something only one backend
|
||||
renders — a Postgres row-lock mode, say. The generic dialect is enough for
|
||||
a predicate's shape and would quietly drop the rest.
|
||||
"""
|
||||
return str(element.compile(compile_kwargs={"literal_binds": True}))
|
||||
return str(element.compile(dialect=dialect, compile_kwargs={"literal_binds": True}))
|
||||
|
||||
|
||||
def make_mock_session() -> AsyncMock:
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""The embedding refresh must LOSE to a delete, not race it (#3262).
|
||||
|
||||
Both upserts replace a record's vectors as delete-then-insert. That takes two
|
||||
row locks — the chunk rows, then the parent row via the insert's foreign key —
|
||||
in the exact reverse of the order a cascading delete of the parent takes them.
|
||||
Postgres calls that a deadlock and kills one side at random, which sometimes
|
||||
means killing the user's delete.
|
||||
|
||||
These pin the ORDER, not the outcome: the claim on the parent goes first, and
|
||||
when the claim fails nothing else in the transaction runs. Compiling the
|
||||
statement is the only way to assert on a lock mode without a database — the
|
||||
integration twin (test_integration_embedding_yields_to_delete.py) proves the
|
||||
behaviour against a real one.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from scribe.services import embeddings as emb
|
||||
from tests.helpers import compiled_sql
|
||||
|
||||
ONE_VECTOR = [[0.0] * 384]
|
||||
|
||||
# The lock mode is a Postgres extension — the generic dialect renders a plain
|
||||
# FOR UPDATE and would pass an assertion that proves nothing.
|
||||
PG = postgresql.dialect()
|
||||
|
||||
|
||||
def _mock_session(lock_result: object = 7, execute_side_effect=None):
|
||||
"""A session stand-in whose first execute answers the parent-row claim."""
|
||||
session = MagicMock()
|
||||
claimed = MagicMock()
|
||||
claimed.scalar_one_or_none.return_value = lock_result
|
||||
if execute_side_effect is not None:
|
||||
session.execute = AsyncMock(side_effect=execute_side_effect)
|
||||
else:
|
||||
session.execute = AsyncMock(return_value=claimed)
|
||||
session.commit = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
ctx = MagicMock()
|
||||
ctx.__aenter__ = AsyncMock(return_value=session)
|
||||
ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
return session, ctx
|
||||
|
||||
|
||||
def _lock_unavailable() -> OperationalError:
|
||||
"""What asyncpg raises through SQLAlchemy when NOWAIT can't take the row."""
|
||||
return OperationalError("SELECT ...", {}, Exception("lock not available"))
|
||||
|
||||
|
||||
async def test_a_note_refresh_claims_the_row_before_rewriting_its_vectors():
|
||||
"""The claim is FIRST, and it is FOR KEY SHARE NOWAIT.
|
||||
|
||||
FOR KEY SHARE because that is exactly the lock the insert's foreign key
|
||||
takes anyway — it conflicts with a delete of the note and with nothing
|
||||
else, so an ordinary edit is unaffected. NOWAIT because the whole point is
|
||||
to lose immediately rather than queue up behind the delete and hold the
|
||||
chunk rows while doing it.
|
||||
"""
|
||||
session, ctx = _mock_session()
|
||||
with (
|
||||
patch.object(emb, "async_session", return_value=ctx),
|
||||
patch.object(emb, "get_embeddings", AsyncMock(return_value=ONE_VECTOR)),
|
||||
):
|
||||
await emb.upsert_note_embedding(7, 42, "T", "a short body")
|
||||
|
||||
claim, replace = [c.args[0] for c in session.execute.call_args_list][:2]
|
||||
assert compiled_sql(claim, dialect=PG).startswith("SELECT notes.id")
|
||||
assert "FOR KEY SHARE NOWAIT" in compiled_sql(claim, dialect=PG)
|
||||
assert compiled_sql(replace, dialect=PG).startswith("DELETE FROM note_embeddings")
|
||||
session.add.assert_called()
|
||||
|
||||
|
||||
async def test_a_rule_refresh_claims_the_row_before_rewriting_its_vectors():
|
||||
"""The rule twin — the path the reported deadlock actually took."""
|
||||
session, ctx = _mock_session()
|
||||
with (
|
||||
patch.object(emb, "async_session", return_value=ctx),
|
||||
patch.object(emb, "get_embeddings", AsyncMock(return_value=ONE_VECTOR)),
|
||||
):
|
||||
await emb.upsert_rule_embedding(9, "T", "a short statement", "on write")
|
||||
|
||||
claim, replace = [c.args[0] for c in session.execute.call_args_list][:2]
|
||||
assert compiled_sql(claim, dialect=PG).startswith("SELECT rules.id")
|
||||
assert "FOR KEY SHARE NOWAIT" in compiled_sql(claim, dialect=PG)
|
||||
assert compiled_sql(replace, dialect=PG).startswith("DELETE FROM rule_embeddings")
|
||||
session.add.assert_called()
|
||||
|
||||
|
||||
async def test_a_record_being_deleted_is_left_alone_rather_than_raced():
|
||||
"""The claim failing ends the write — it does not fall through to the
|
||||
delete-and-insert that would take the locks in the losing order."""
|
||||
session, ctx = _mock_session(execute_side_effect=_lock_unavailable())
|
||||
with (
|
||||
patch.object(emb, "async_session", return_value=ctx),
|
||||
patch.object(emb, "get_embeddings", AsyncMock(return_value=ONE_VECTOR)),
|
||||
):
|
||||
await emb.upsert_rule_embedding(9, "T", "a short statement", "on write")
|
||||
|
||||
assert session.execute.await_count == 1, "it stopped at the claim"
|
||||
session.add.assert_not_called()
|
||||
session.commit.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_a_record_already_gone_is_not_re_embedded():
|
||||
"""A vector inserted for a row that no longer exists is either a foreign
|
||||
key violation or, worse, a resurrected chunk. Nothing to refresh."""
|
||||
session, ctx = _mock_session(lock_result=None)
|
||||
with (
|
||||
patch.object(emb, "async_session", return_value=ctx),
|
||||
patch.object(emb, "get_embeddings", AsyncMock(return_value=ONE_VECTOR)),
|
||||
):
|
||||
await emb.upsert_note_embedding(7, 42, "T", "a short body")
|
||||
|
||||
assert session.execute.await_count == 1
|
||||
session.add.assert_not_called()
|
||||
session.commit.assert_not_awaited()
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Every request the web UI makes has a deadline (rule 156).
|
||||
|
||||
A source-inspection guard in the unit lane — there is no frontend test runner,
|
||||
and this is a property of the source rather than of a rendered result, so
|
||||
reading the source is the honest way to check it.
|
||||
|
||||
WHY. `fetch`'s default is to wait as long as the browser will. That is not a
|
||||
long timeout, it is the absence of one, and there is no state a surface can
|
||||
render for "pending forever" that is not a lie — the spinner that never
|
||||
resolves is indistinguishable from work still in progress. Rule 156 names
|
||||
`fetch` specifically:
|
||||
|
||||
When a library's default is "wait indefinitely" — `fetch`, most HTTP
|
||||
clients, a bare `await` on a stream — supplying the deadline is part of
|
||||
using it, not a hardening pass for later.
|
||||
|
||||
Before this guard, no request in the app carried one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend" / "src"
|
||||
CLIENT = FRONTEND / "api" / "client.ts"
|
||||
|
||||
|
||||
def _call_text(src: str, start: int) -> str:
|
||||
"""The source of one `fetch(...)` call, from its open paren to its close.
|
||||
|
||||
Naive paren balancing. Adequate because every call site here passes an
|
||||
object literal, and a construct complex enough to defeat it is one worth
|
||||
looking at by hand anyway.
|
||||
"""
|
||||
depth = 0
|
||||
for i in range(start, len(src)):
|
||||
if src[i] == "(":
|
||||
depth += 1
|
||||
elif src[i] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start:i + 1]
|
||||
return src[start:]
|
||||
|
||||
|
||||
def _fetch_calls() -> list[tuple[Path, str]]:
|
||||
calls: list[tuple[Path, str]] = []
|
||||
for path in list(FRONTEND.rglob("*.ts")) + list(FRONTEND.rglob("*.vue")):
|
||||
src = path.read_text()
|
||||
for m in re.finditer(r"\bfetch\(", src):
|
||||
calls.append((path, _call_text(src, m.end() - 1)))
|
||||
return calls
|
||||
|
||||
|
||||
def test_every_fetch_passes_a_signal():
|
||||
"""No bare `fetch` anywhere in the frontend.
|
||||
|
||||
Stated on the SIGNAL rather than on a timeout value, because the two
|
||||
legitimate shapes here produce different values and only share this: an
|
||||
ordinary call takes the client's default, a stream bounds its CONNECT and
|
||||
then deliberately runs unbounded, and a bulk transfer passes minutes. What
|
||||
they must all do is pass something.
|
||||
"""
|
||||
naked = [
|
||||
f"{path.relative_to(FRONTEND)}: {call[:70]}"
|
||||
for path, call in _fetch_calls()
|
||||
if "signal:" not in call
|
||||
]
|
||||
assert not naked, (
|
||||
"these fetch calls carry no AbortSignal, so they wait forever "
|
||||
"(rule 156):\n " + "\n ".join(naked)
|
||||
)
|
||||
|
||||
|
||||
def test_the_client_applies_its_deadline_by_default():
|
||||
"""The specific regression that would silently undo this.
|
||||
|
||||
An earlier pass (#3329) made `timeoutMs` OPT-IN and used it at exactly one
|
||||
call site, which left ~330 others waiting forever while the mechanism
|
||||
looked present. Reverting to that shape would not fail the guard above —
|
||||
every call would still reach `fetch` through `request()` — so the default
|
||||
is pinned here separately.
|
||||
|
||||
`??` is the operative character: `opts?.timeoutMs || DEFAULT` would treat
|
||||
an explicit 0 as "use the default", and `opts?.timeoutMs` alone would
|
||||
reinstate the opt-in bug.
|
||||
"""
|
||||
src = CLIENT.read_text()
|
||||
assert "opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS" in src, (
|
||||
"request() must fall back to DEFAULT_TIMEOUT_MS — without it the "
|
||||
"deadline is opt-in again and almost nothing opts in"
|
||||
)
|
||||
|
||||
|
||||
def test_a_timeout_arrives_as_the_error_shape_callers_already_handle():
|
||||
"""Rule 156's second half: expiry surfaces as a NAMED failure.
|
||||
|
||||
A raw `DOMException: TimeoutError` reaches `apiErrorMessage(e, fallback)`
|
||||
as an object with no `body`, so every catch site in the app would print its
|
||||
generic fallback and the timeout would be invisible in exactly the
|
||||
situation it exists to expose. Rethrowing as `ApiError` is what makes the
|
||||
other ~330 call sites report it without being edited.
|
||||
"""
|
||||
src = CLIENT.read_text()
|
||||
assert 'e.name === "TimeoutError"' in src, (
|
||||
"request() must recognise a timeout specifically"
|
||||
)
|
||||
assert "new ApiError(CLIENT_TIMEOUT_STATUS" in src, (
|
||||
"a timeout must be rethrown as ApiError so apiErrorMessage can read it"
|
||||
)
|
||||
|
||||
|
||||
def test_a_deliberate_cancellation_is_not_reported_as_a_timeout():
|
||||
"""Only `TimeoutError` is converted, never `AbortError`.
|
||||
|
||||
A caller that cancelled its own request — a superseded search, a closed
|
||||
stream — must not have that surfaced to the user as a server failure. The
|
||||
guard is that the conversion is gated on the name, which the assertion
|
||||
above already pins; this states the intent so the gate is not "simplified"
|
||||
into catching every abort.
|
||||
"""
|
||||
src = CLIENT.read_text()
|
||||
convert = src[src.index("async function request<"):]
|
||||
convert = convert[:convert.index("\n}")]
|
||||
assert "AbortError" not in convert, (
|
||||
"request() must not convert AbortError — a deliberate cancellation is "
|
||||
"not a timeout"
|
||||
)
|
||||
@@ -15,14 +15,17 @@ def test_rules_payload_carries_excluded_always_on_as_the_seventh_key():
|
||||
out = rules_payload({
|
||||
"rules": [], "truncated": False, "subscribed_rulebooks": [],
|
||||
"excluded_always_on": [{"id": 1, "title": "Family"}],
|
||||
})
|
||||
}, user_id=1, source="enter_project")
|
||||
assert set(out) == {
|
||||
"applicable_rules", "applicable_rules_truncated", "subscribed_rulebooks",
|
||||
"project_rules", "suppressed_rules", "suppressed_topics", "excluded_always_on",
|
||||
}
|
||||
assert out["excluded_always_on"] == [{"id": 1, "title": "Family"}]
|
||||
# An older applicable dict without the key still renders (empty list).
|
||||
assert rules_payload({"rules": [], "truncated": False, "subscribed_rulebooks": []})["excluded_always_on"] == []
|
||||
assert rules_payload(
|
||||
{"rules": [], "truncated": False, "subscribed_rulebooks": []},
|
||||
user_id=1, source="enter_project",
|
||||
)["excluded_always_on"] == []
|
||||
|
||||
|
||||
def test_list_always_on_rules_service_and_tool_take_a_project_id():
|
||||
|
||||
@@ -195,6 +195,46 @@ def test_displaced_topics_live_on_a_delivered_surface():
|
||||
)
|
||||
|
||||
|
||||
# The SECOND pull (milestone 333 step 3). `list_always_on_rules()` fetches the
|
||||
# resident tier; this one says that tier is not all of them, and that a
|
||||
# conditional rule has to be gone looking for. However a surface words the
|
||||
# surrounding prose, it names the call.
|
||||
RETRIEVE = 'content_type="rule"'
|
||||
|
||||
|
||||
def test_every_session_start_surface_states_the_conditional_retrieval():
|
||||
"""The push/pull asymmetry, one level in.
|
||||
|
||||
The tests above pin that a session PULLS the resident rules rather than
|
||||
trusting the SessionStart push. This pins the same shape between the two
|
||||
TIERS: an always-on rule is delivered, a conditional one is retrieved, and
|
||||
a surface that states only the first leaves a session reading its loaded
|
||||
set as the whole rulebook.
|
||||
|
||||
That reading is wrong in the direction that costs something. "Nothing was
|
||||
pushed" and "no rule applies" are different claims, and only one of them
|
||||
has been checked — the same asymmetry as #2198, now between tiers instead
|
||||
of between channels.
|
||||
|
||||
It is also what made the always-on tier the only one that worked, on any
|
||||
install rather than this one (rule 115). A rule nothing retrieves has to be
|
||||
resident to bind at all, so every rule worth keeping becomes resident; and
|
||||
a resident rule costs tokens in every session forever, so a rulebook that
|
||||
only delivers cannot grow past what one session can hold. Retrieval is what
|
||||
lifts that ceiling — and it only fires if something asks.
|
||||
"""
|
||||
missing = []
|
||||
for path in SESSION_START_SURFACES:
|
||||
if RETRIEVE not in path.read_text():
|
||||
missing.append(str(path.relative_to(ROOT)))
|
||||
assert not missing, (
|
||||
f"these surfaces state the always-on pull but never tell the agent to "
|
||||
f"retrieve a conditional rule ({RETRIEVE}): {missing}. A session that "
|
||||
f"reads its loaded set as the whole rulebook will act on \"I was not "
|
||||
f"told\" as if it meant \"there is no rule\" (milestone 333 step 3)."
|
||||
)
|
||||
|
||||
|
||||
def test_no_surface_names_the_push_without_stating_the_pull():
|
||||
"""The exact shape #2497 took.
|
||||
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Real-Postgres round trip for rule_usage_events (milestone 333 step 1).
|
||||
|
||||
**This file is the reason the table exists.** `rule_usage_events` could have
|
||||
been a `rule_id` column on `note_usage_events` — the row carries no
|
||||
note-specific field and the readout is the same shape, which is the strongest
|
||||
case for sharing that note #3163 admits. What decided against it is identity at
|
||||
restore, and that is a claim only a real round trip can support.
|
||||
|
||||
The failure it guards is the quiet kind. `note_usage_events`'s importer maps
|
||||
`note_id` through `note_id_map`; a rule id parked in that column comes back
|
||||
attached to whatever note happens to hold that number in the target database.
|
||||
Not dropped — REATTACHED. The restore reports success, the counters are
|
||||
populated, and every one of them is about the wrong record. Nothing downstream
|
||||
can detect it, because a usage row has no other field to disagree with.
|
||||
|
||||
So the assertions below are about WHICH MAP resolved the id, and they are
|
||||
written to fail if the answer ever becomes "the note one" or "neither".
|
||||
|
||||
Same shape as `test_integration_backup_rule_version_roundtrip.py`, which guards
|
||||
`rule_versions` against #3182's `arose_from_id` trap on the same seam.
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.rule_usage import PULLED, SURFACED, RuleUsageEvent
|
||||
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
|
||||
from scribe.models.user import User
|
||||
from scribe.services import backup
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
|
||||
OWNER_USERNAME = "rule_usage_roundtrip_owner"
|
||||
RESTORED_USERNAME = "rule_usage_roundtrip_restored"
|
||||
|
||||
|
||||
async def _purge_books(username: str) -> None:
|
||||
"""user -> rulebook -> topic -> rule is ON DELETE CASCADE the whole way,
|
||||
so dropping the books clears the rules this file made.
|
||||
|
||||
`rule_usage_events` is deliberately FK-FREE, so its rows do NOT cascade —
|
||||
that is the property under test elsewhere (telemetry outlives what it
|
||||
describes). They are cleared explicitly below.
|
||||
"""
|
||||
async with async_session() as s:
|
||||
users = (await s.execute(
|
||||
select(User).where(User.username == username)
|
||||
)).scalars().all()
|
||||
for user in users:
|
||||
books = (await s.execute(
|
||||
select(Rulebook).where(Rulebook.owner_user_id == user.id)
|
||||
)).scalars().all()
|
||||
for book in books:
|
||||
await s.delete(book)
|
||||
for note in (await s.execute(
|
||||
select(Note).where(Note.user_id == user.id)
|
||||
)).scalars().all():
|
||||
await s.delete(note)
|
||||
await s.commit()
|
||||
|
||||
|
||||
async def _purge_usage(rule_ids: set[int]) -> None:
|
||||
if not rule_ids:
|
||||
return
|
||||
async with async_session() as s:
|
||||
for ev in (await s.execute(
|
||||
select(RuleUsageEvent).where(RuleUsageEvent.rule_id.in_(rule_ids))
|
||||
)).scalars().all():
|
||||
await s.delete(ev)
|
||||
await s.commit()
|
||||
|
||||
|
||||
async def _purge_restored() -> None:
|
||||
await _purge_books(RESTORED_USERNAME)
|
||||
async with async_session() as s:
|
||||
for user in (await s.execute(
|
||||
select(User).where(User.username == RESTORED_USERNAME)
|
||||
)).scalars().all():
|
||||
await s.delete(user)
|
||||
await s.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _no_leftovers():
|
||||
"""SETUP ONLY — see the sibling file for why a database call after a
|
||||
`yield` here orphans a pooled connection and breaks unrelated tests."""
|
||||
await _purge_restored()
|
||||
await _purge_books(OWNER_USERNAME)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def source():
|
||||
"""One rule with a surfaced/pulled pair — plus a NOTE that will hold the
|
||||
rule's id in the restored database.
|
||||
|
||||
That note is the whole trick. Without it, a restore that ran rule ids
|
||||
through `note_id_map` would simply drop them and the test would read as a
|
||||
pass-by-absence. With it, the wrong map produces a plausible, populated,
|
||||
entirely wrong result — which is the failure actually being guarded.
|
||||
"""
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, OWNER_USERNAME)
|
||||
uid = owner.id
|
||||
await s.commit()
|
||||
|
||||
async with async_session() as s:
|
||||
book = Rulebook(owner_user_id=uid, title="Environment facts")
|
||||
s.add(book)
|
||||
await s.flush()
|
||||
topic = RulebookTopic(rulebook_id=book.id, title="ci")
|
||||
s.add(topic)
|
||||
await s.flush()
|
||||
rule = Rule(
|
||||
topic_id=topic.id,
|
||||
title="A wait with no deadline is a bug",
|
||||
statement="Every wait on something that can fail to answer carries one.",
|
||||
)
|
||||
s.add(rule)
|
||||
# A note in the same export, so the target database has a note id to
|
||||
# collide with. Its own id is irrelevant; what matters is that the
|
||||
# note map is populated and would resolve to something.
|
||||
note = Note(user_id=uid, title="a note that must not receive rule telemetry",
|
||||
body="decoy")
|
||||
s.add(note)
|
||||
await s.flush()
|
||||
s.add_all([
|
||||
RuleUsageEvent(
|
||||
user_id=uid, rule_id=rule.id,
|
||||
event=SURFACED, source="write_path_rule",
|
||||
),
|
||||
RuleUsageEvent(
|
||||
user_id=uid, rule_id=rule.id,
|
||||
event=PULLED, source="mcp_get_rule",
|
||||
),
|
||||
# No actor. The arm can fire for an unauthenticated hook call, and
|
||||
# a user who later leaves must not take the evidence with them.
|
||||
RuleUsageEvent(
|
||||
user_id=None, rule_id=rule.id,
|
||||
event=SURFACED, source="write_path_rule",
|
||||
),
|
||||
])
|
||||
await s.commit()
|
||||
book_id, rule_id, note_id = book.id, rule.id, note.id
|
||||
|
||||
async with async_session() as s:
|
||||
user_rows = backup._user_rows(
|
||||
[(await s.execute(select(User).where(User.id == uid))).scalars().one()]
|
||||
)
|
||||
book_rows = backup._rulebook_rows(
|
||||
[(await s.execute(select(Rulebook).where(Rulebook.id == book_id)))
|
||||
.scalars().one()]
|
||||
)
|
||||
topic_rows = backup._topic_rows(
|
||||
(await s.execute(
|
||||
select(RulebookTopic).where(RulebookTopic.rulebook_id == book_id)
|
||||
)).scalars().all()
|
||||
)
|
||||
rule_rows = backup._rule_rows(
|
||||
[(await s.execute(select(Rule).where(Rule.id == rule_id))).scalars().one()]
|
||||
)
|
||||
note_rows = backup._note_rows(
|
||||
[(await s.execute(select(Note).where(Note.id == note_id))).scalars().one()]
|
||||
)
|
||||
usage_rows = backup._rule_usage_event_rows(
|
||||
(await s.execute(
|
||||
select(RuleUsageEvent).where(RuleUsageEvent.rule_id == rule_id)
|
||||
.order_by(RuleUsageEvent.id)
|
||||
)).scalars().all()
|
||||
)
|
||||
user_rows[0]["username"] = RESTORED_USERNAME
|
||||
|
||||
yield {
|
||||
"payload": {
|
||||
"version": backup.BACKUP_VERSION,
|
||||
"users": user_rows,
|
||||
"rulebooks": book_rows,
|
||||
"rulebook_topics": topic_rows,
|
||||
"rules": rule_rows,
|
||||
"notes": note_rows,
|
||||
"rule_usage_events": usage_rows,
|
||||
},
|
||||
"source_rule_id": rule_id,
|
||||
"source_user_id": uid,
|
||||
}
|
||||
|
||||
await _purge_usage({rule_id})
|
||||
async with async_session() as s:
|
||||
book = await s.get(Rulebook, book_id)
|
||||
if book is not None:
|
||||
await s.delete(book)
|
||||
note = await s.get(Note, note_id)
|
||||
if note is not None:
|
||||
await s.delete(note)
|
||||
await s.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def restored(source):
|
||||
await backup.restore_full_backup(source["payload"])
|
||||
async with async_session() as s:
|
||||
user = (await s.execute(
|
||||
select(User).where(User.username == RESTORED_USERNAME)
|
||||
)).scalars().first()
|
||||
assert user is not None, "the payload's user was not restored"
|
||||
book = (await s.execute(
|
||||
select(Rulebook).where(Rulebook.owner_user_id == user.id)
|
||||
)).scalars().one()
|
||||
topic = (await s.execute(
|
||||
select(RulebookTopic).where(RulebookTopic.rulebook_id == book.id)
|
||||
)).scalars().one()
|
||||
rule = (await s.execute(
|
||||
select(Rule).where(Rule.topic_id == topic.id)
|
||||
)).scalars().one()
|
||||
note = (await s.execute(
|
||||
select(Note).where(Note.user_id == user.id)
|
||||
)).scalars().one()
|
||||
events = (await s.execute(
|
||||
select(RuleUsageEvent).where(RuleUsageEvent.rule_id == rule.id)
|
||||
.order_by(RuleUsageEvent.id)
|
||||
)).scalars().all()
|
||||
yield {
|
||||
"user": user, "rule": rule, "note": note,
|
||||
"events": events, "source": source,
|
||||
}
|
||||
|
||||
await _purge_usage({rule.id})
|
||||
await _purge_restored()
|
||||
|
||||
|
||||
async def test_every_event_comes_back(restored):
|
||||
"""The count first: every shape assertion below reads the same on an empty
|
||||
list, so without this a restore that dropped all three would pass them."""
|
||||
assert len(restored["events"]) == 3
|
||||
|
||||
|
||||
async def test_the_events_attach_to_the_RESTORED_rule(restored):
|
||||
"""The remap, on the column that matters."""
|
||||
new_rule_id = restored["rule"].id
|
||||
source_rule_id = restored["source"]["source_rule_id"]
|
||||
assert new_rule_id != source_rule_id, (
|
||||
"the restore reused the source id, so this test cannot tell a remap "
|
||||
"from a copy — the fixture is not proving what it claims"
|
||||
)
|
||||
assert {e.rule_id for e in restored["events"]} == {new_rule_id}
|
||||
|
||||
|
||||
async def test_no_event_landed_on_the_note_id(restored):
|
||||
"""THE ONE THIS TABLE EXISTS FOR.
|
||||
|
||||
If `rule_id` were ever resolved through `note_id_map` — the shape it would
|
||||
have had as a column on `note_usage_events` — these rows would come back
|
||||
pointing at the restored NOTE's id. Populated, plausible, and describing a
|
||||
record that was never surfaced.
|
||||
"""
|
||||
note_id = restored["note"].id
|
||||
landed_on_note = [e for e in restored["events"] if e.rule_id == note_id]
|
||||
assert not landed_on_note, (
|
||||
f"{len(landed_on_note)} usage event(s) resolved to the note's id "
|
||||
f"({note_id}) instead of the rule's. The rule id went through the "
|
||||
"note map — telemetry that is wrong rather than missing, and that "
|
||||
"nothing downstream can detect."
|
||||
)
|
||||
|
||||
|
||||
async def test_the_actor_is_remapped_and_a_missing_one_survives(restored):
|
||||
"""`user_id` is an id in the source database too — the same trap one
|
||||
column over. And the actorless row must not be dropped: the arm can fire
|
||||
for an unauthenticated hook call, so requiring an actor would discard the
|
||||
surfacings of exactly the surface being measured."""
|
||||
attributed = [e for e in restored["events"] if e.user_id is not None]
|
||||
orphaned = [e for e in restored["events"] if e.user_id is None]
|
||||
assert len(attributed) == 2
|
||||
assert len(orphaned) == 1, (
|
||||
"the event with no actor did not come back. Telemetry outlives the "
|
||||
"account it was recorded for; dropping it silently lowers the "
|
||||
"surfaced count that the pull-through ratio divides by."
|
||||
)
|
||||
assert {e.user_id for e in attributed} == {restored["user"].id}
|
||||
assert restored["user"].id != restored["source"]["source_user_id"]
|
||||
|
||||
|
||||
async def test_the_event_and_source_survive(restored):
|
||||
"""The two fields the ratio is computed from. A restore that kept the rows
|
||||
and lost these would preserve a count of nothing in particular."""
|
||||
pairs = {(e.event, e.source) for e in restored["events"]}
|
||||
assert pairs == {
|
||||
(SURFACED, "write_path_rule"),
|
||||
(PULLED, "mcp_get_rule"),
|
||||
}
|
||||
assert sum(1 for e in restored["events"] if e.event == SURFACED) == 2
|
||||
assert sum(1 for e in restored["events"] if e.event == PULLED) == 1
|
||||
@@ -0,0 +1,173 @@
|
||||
"""#3262 against a real Postgres: the embedder loses the race, it doesn't run it.
|
||||
|
||||
The reported failure was a deadlock — `DELETE FROM rulebooks` killed by the
|
||||
server while a detached `upsert_rule_embedding` held the other half of the
|
||||
cycle. It cannot be reproduced with mocks, because there is nothing to
|
||||
deadlock: the whole bug lives in the ORDER two transactions take two row
|
||||
locks, which only a lock manager can adjudicate.
|
||||
|
||||
So each test here holds a real delete open in one transaction and calls the
|
||||
embedder in another. What is being pinned is that the embedder RETURNS —
|
||||
promptly, having written nothing. Before the fix it would sit on the chunk
|
||||
rows waiting for a delete that is itself waiting on the insert's foreign key,
|
||||
and the test would hang rather than fail, which is why every call carries a
|
||||
deadline (rule 156).
|
||||
"""
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.embedding import NoteEmbedding, RuleEmbedding
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.rulebook import Rulebook
|
||||
from scribe.services import embeddings as emb
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
|
||||
OWNER_USERNAME = "embed_lock_owner"
|
||||
|
||||
# Generous, because the assertion is "it did not block indefinitely", not "it
|
||||
# was fast". A machine under load must not turn this into a flake; a genuinely
|
||||
# blocked embedder never returns at all, so no honest run comes near this.
|
||||
YIELD_DEADLINE_SECONDS = 20
|
||||
|
||||
# What the embedder would write if it wrongly went ahead. Distinct from the
|
||||
# text the fixture's own create_* wrote, so the assertion cannot be satisfied
|
||||
# by rows that were already there.
|
||||
SENTINEL = "sentinelvector"
|
||||
|
||||
ONE_VECTOR = [[0.0] * 384]
|
||||
|
||||
# The fixture's own embedding task runs the REAL embedder, which either loads a
|
||||
# model or gives up; both are bounded well inside this.
|
||||
SETTLE_DEADLINE_SECONDS = 30
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seeded():
|
||||
"""A rule and a note to race against.
|
||||
|
||||
CLEANED AT SETUP, NOT TEARDOWN — the same constraint #3241 hit and the
|
||||
reason this file exists. `create_rule` fires its own detached embedding
|
||||
task; a teardown that deleted the rulebook would be racing exactly the
|
||||
thing under test, on a loop that is closing.
|
||||
"""
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, OWNER_USERNAME)
|
||||
uid = owner.id
|
||||
await s.commit()
|
||||
for book in (await s.execute(
|
||||
select(Rulebook).where(Rulebook.owner_user_id == uid)
|
||||
)).scalars().all():
|
||||
await s.delete(book)
|
||||
for note in (await s.execute(
|
||||
select(Note).where(Note.user_id == uid)
|
||||
)).scalars().all():
|
||||
await s.delete(note)
|
||||
await s.commit()
|
||||
|
||||
book = await rulebooks_svc.create_rulebook(uid, "Lock fixtures")
|
||||
topic = await rulebooks_svc.create_topic(book.id, uid, "locks")
|
||||
rule = await rulebooks_svc.create_rule(
|
||||
topic.id, uid, "A rule with vectors",
|
||||
"Something for the embedder to index.",
|
||||
)
|
||||
async with async_session() as s:
|
||||
note = Note(user_id=uid, title="A note with vectors", body="Body text.")
|
||||
s.add(note)
|
||||
await s.commit()
|
||||
note_id = note.id
|
||||
|
||||
await _settle_detached_writes()
|
||||
return {"uid": uid, "book_id": book.id, "rule_id": rule.id, "note_id": note_id}
|
||||
|
||||
|
||||
async def _settle_detached_writes() -> None:
|
||||
"""Let `create_rule`'s own fire-and-forget embedding task finish.
|
||||
|
||||
It is the same detached write these tests are about, aimed at the same
|
||||
rule, and left in flight it would land in the middle of an assertion about
|
||||
that rule's rows. Bounded, and a timeout is not a failure — the tests below
|
||||
carry their own deadlines, and this is only tidying the start line.
|
||||
"""
|
||||
pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
||||
if pending:
|
||||
await asyncio.wait(pending, timeout=SETTLE_DEADLINE_SECONDS)
|
||||
|
||||
|
||||
async def _rule_chunks(rule_id: int) -> list[str]:
|
||||
async with async_session() as s:
|
||||
return list((await s.execute(
|
||||
select(RuleEmbedding.chunk_text).where(RuleEmbedding.rule_id == rule_id)
|
||||
)).scalars().all())
|
||||
|
||||
|
||||
async def _note_chunks(note_id: int) -> list[str]:
|
||||
async with async_session() as s:
|
||||
return list((await s.execute(
|
||||
select(NoteEmbedding.chunk_text).where(NoteEmbedding.note_id == note_id)
|
||||
)).scalars().all())
|
||||
|
||||
|
||||
async def test_a_rule_refresh_yields_to_a_delete_cascading_from_its_rulebook(seeded):
|
||||
"""The reported case, exactly: the delete lands on the RULEBOOK and reaches
|
||||
the rule through two cascades, which is why nothing on the rule's own write
|
||||
path could have seen it coming."""
|
||||
async with async_session() as blocker:
|
||||
# Uncommitted on purpose — the cascade's locks are held for as long as
|
||||
# this transaction stays open, which is the state the embedder must
|
||||
# decline to fight over.
|
||||
await blocker.execute(delete(Rulebook).where(Rulebook.id == seeded["book_id"]))
|
||||
try:
|
||||
with patch.object(emb, "get_embeddings", AsyncMock(return_value=ONE_VECTOR)):
|
||||
await asyncio.wait_for(
|
||||
emb.upsert_rule_embedding(
|
||||
seeded["rule_id"], SENTINEL, f"{SENTINEL} statement",
|
||||
),
|
||||
timeout=YIELD_DEADLINE_SECONDS,
|
||||
)
|
||||
finally:
|
||||
await blocker.rollback()
|
||||
|
||||
assert not any(SENTINEL in text for text in await _rule_chunks(seeded["rule_id"])), \
|
||||
"the embedder wrote into a rule that was being deleted"
|
||||
|
||||
|
||||
async def test_a_note_refresh_yields_to_a_delete_of_the_note(seeded):
|
||||
"""The note twin, which #3262 recorded as unverified. Notes are soft-deleted
|
||||
day to day, so the hard delete a trash purge issues is the one that can put
|
||||
a lock on the row while a refresh is in flight."""
|
||||
async with async_session() as blocker:
|
||||
await blocker.execute(delete(Note).where(Note.id == seeded["note_id"]))
|
||||
try:
|
||||
with patch.object(emb, "get_embeddings", AsyncMock(return_value=ONE_VECTOR)):
|
||||
await asyncio.wait_for(
|
||||
emb.upsert_note_embedding(
|
||||
seeded["note_id"], seeded["uid"], SENTINEL, f"{SENTINEL} body",
|
||||
),
|
||||
timeout=YIELD_DEADLINE_SECONDS,
|
||||
)
|
||||
finally:
|
||||
await blocker.rollback()
|
||||
|
||||
assert not any(SENTINEL in text for text in await _note_chunks(seeded["note_id"])), \
|
||||
"the embedder wrote into a note that was being deleted"
|
||||
|
||||
|
||||
async def test_an_uncontended_refresh_still_writes(seeded):
|
||||
"""The guard against the cheapest possible false pass: a claim that never
|
||||
succeeds would satisfy both tests above while quietly ending semantic
|
||||
search."""
|
||||
with patch.object(emb, "get_embeddings", AsyncMock(return_value=ONE_VECTOR)):
|
||||
await emb.upsert_rule_embedding(
|
||||
seeded["rule_id"], SENTINEL, f"{SENTINEL} statement",
|
||||
)
|
||||
|
||||
assert any(SENTINEL in text for text in await _rule_chunks(seeded["rule_id"])), \
|
||||
"an unlocked rule was not embedded"
|
||||
@@ -154,7 +154,8 @@ async def test_unscored_location_arms_are_recorded(lookups, expected_source):
|
||||
patch.object(
|
||||
plugin_context,
|
||||
"get_writepath_config",
|
||||
AsyncMock(return_value={"enabled": True, "threshold": 0.55, "top_k": 3}),
|
||||
AsyncMock(return_value={"enabled": True, "threshold": 0.55, "top_k": 3,
|
||||
"rule_threshold": 0.72}),
|
||||
),
|
||||
patch.object(
|
||||
plugin_context.snippets_svc,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""One definition of what SHIPS in the plugin, and the drift guards on it.
|
||||
|
||||
WHAT THIS IS ABOUT (#3127 §3, milestone 334 step 2). Scribe publishes two
|
||||
artifacts. `plugin/` is not in the Docker image — installs fetch it from this
|
||||
repo through `.claude-plugin/marketplace.json`, so **a push IS the release**,
|
||||
with no build step in between. That makes "which files reach an install?" a
|
||||
question with real consequences, and it has been answered wrong twice:
|
||||
|
||||
- #2198 — `plugin/**` was in no `paths:` filter, so four broken hooks
|
||||
reached live installs having triggered no CI at all.
|
||||
- #2209 — the fix for that shipped and still could not reach an install,
|
||||
because the manifest version had not moved.
|
||||
|
||||
The set lives in `scripts/check_plugin.py`. Its second consumer is the
|
||||
workflow's `paths:` trigger, which is YAML and cannot import Python — so the
|
||||
"one definition" is held together by the drift tests here rather than by an
|
||||
import. That is the honest shape, and it is why these tests exist at all.
|
||||
|
||||
The exclusion tests are the load-bearing half. Without the manifest-`version`
|
||||
exclusion the version check is CIRCULAR: bumping the version edits a file
|
||||
inside `plugin/`, which then reads as the content change that justifies the
|
||||
bump. Every bump passes, no bump ever fails, and the check has proved nothing
|
||||
while looking green.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.check_plugin import (
|
||||
DERIVERS,
|
||||
SHIPPED_PATHS,
|
||||
manifest_differs_beyond_version,
|
||||
)
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
CI = ROOT / ".forgejo/workflows/ci.yml"
|
||||
|
||||
|
||||
def trigger_paths() -> list[str]:
|
||||
"""The `paths:` list under the workflow's push trigger.
|
||||
|
||||
Parsed with a regex rather than a YAML library, matching what
|
||||
test_version_endpoint.py already does with this file — the alternative is
|
||||
adding PyYAML as a dependency for one assertion. Raises rather than
|
||||
returning empty: a silent no-op here would defeat the point of the file.
|
||||
"""
|
||||
text = CI.read_text()
|
||||
block = re.search(r"^ paths:\n((?:(?: [-#].*)?\n)+)", text, re.M)
|
||||
if block is None:
|
||||
raise AssertionError("could not find the push trigger's `paths:` block")
|
||||
found = re.findall(r'^ - "([^"]+)"', block.group(1), re.M)
|
||||
if not found:
|
||||
raise AssertionError("the `paths:` block parsed to zero entries")
|
||||
return found
|
||||
|
||||
|
||||
# ── The set itself ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_every_shipped_path_exists():
|
||||
"""A set naming something that isn't there is not a definition of anything."""
|
||||
for path in SHIPPED_PATHS:
|
||||
assert (ROOT / path).exists(), f"SHIPPED_PATHS names {path}, which does not exist"
|
||||
|
||||
|
||||
def test_every_shipped_path_triggers_ci():
|
||||
"""#2198's exact hole, stated as an assertion.
|
||||
|
||||
Directional on purpose: the trigger is a superset (it also fires on
|
||||
`src/**`, `tests/**` and friends). What must never happen is a path that
|
||||
reaches an install and fires no lane.
|
||||
"""
|
||||
triggers = trigger_paths()
|
||||
for path in SHIPPED_PATHS:
|
||||
covered = any(t == path or t.startswith(f"{path}/") for t in triggers)
|
||||
assert covered, (
|
||||
f"{path} ships to installs but no `paths:` entry covers it — "
|
||||
f"changes there would reach a live install having run no CI (#2198)"
|
||||
)
|
||||
|
||||
|
||||
def test_the_checker_itself_triggers_ci():
|
||||
"""Changing the checks must re-run them.
|
||||
|
||||
Not a member of the shipped set — a checker decides whether the lane goes
|
||||
red, not what any artifact reports — but a change to it that runs no lane
|
||||
is the same silence by a different route.
|
||||
"""
|
||||
assert "scripts/check_plugin.py" in trigger_paths()
|
||||
|
||||
|
||||
def test_no_trigger_path_names_something_that_does_not_exist():
|
||||
"""The guard that catches scaffolding outliving its subsystem.
|
||||
|
||||
`fable-mcp/**` sat in this list for three months after the directory was
|
||||
deleted (commit 91bafb6, 2026-05-27), and `assets/**` named a path that
|
||||
never existed at all. Neither ever failed anything — a `paths:` entry
|
||||
matching nothing simply never fires — which is precisely why a list kept
|
||||
by hand drifts and nobody finds out.
|
||||
"""
|
||||
missing = [
|
||||
entry for entry in trigger_paths()
|
||||
if not (ROOT / re.sub(r"/\*\*$", "", entry)).exists()
|
||||
]
|
||||
assert not missing, (
|
||||
f"`paths:` names {missing}, which do not exist in the repo. A trigger "
|
||||
f"that matches nothing is silent, so it survives every review."
|
||||
)
|
||||
|
||||
|
||||
def test_every_deriver_exists():
|
||||
"""§3's table, kept honest.
|
||||
|
||||
The point of the table is that the next artifact is a one-line addition
|
||||
(milestone 334 step 3 adds the plugin's mint script). A row pointing at a
|
||||
file that has moved would make the table read as complete when it is not.
|
||||
"""
|
||||
for path, artifacts in DERIVERS.items():
|
||||
assert (ROOT / path).exists(), f"DERIVERS names {path}, which does not exist"
|
||||
assert artifacts, f"DERIVERS[{path}] names no artifact"
|
||||
|
||||
|
||||
# ── The exclusion — the half that makes the version check mean anything ────
|
||||
|
||||
|
||||
def manifest(**fields) -> str:
|
||||
base = {
|
||||
"name": "scribe",
|
||||
"description": "d",
|
||||
"version": "0.1.48",
|
||||
"mcpServers": {"scribe": {"type": "http", "url": "${user_config.api_endpoint}/mcp"}},
|
||||
"userConfig": {"api_endpoint": {"type": "string"}},
|
||||
}
|
||||
base.update(fields)
|
||||
return json.dumps(base)
|
||||
|
||||
|
||||
def test_a_version_only_change_is_NOT_a_content_change():
|
||||
"""THE assertion. Without it the version check is self-satisfying: the
|
||||
bump edits `plugin.json`, which lives inside `plugin/`, so the bump is its
|
||||
own justification and every bump passes."""
|
||||
assert manifest_differs_beyond_version(
|
||||
manifest(version="2026.09.01.0512"), manifest(version="0.1.48")
|
||||
) is False
|
||||
|
||||
|
||||
def test_an_identical_manifest_is_not_a_change():
|
||||
assert manifest_differs_beyond_version(manifest(), manifest()) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value", [
|
||||
("userConfig", {"api_endpoint": {"type": "string", "title": "changed"}}),
|
||||
("mcpServers", {"scribe": {"type": "http", "url": "elsewhere"}}),
|
||||
("description", "a different description"),
|
||||
("name", "renamed"),
|
||||
])
|
||||
def test_every_OTHER_manifest_field_still_demands_a_new_version(field, value):
|
||||
"""Why the exclusion is one FIELD and never the whole file.
|
||||
|
||||
`plugin.json` carries description, mcpServers and userConfig alongside the
|
||||
version, and all of them reach an install. Excluding the file wholesale
|
||||
would mean a userConfig-only edit computes an unchanged version and never
|
||||
refreshes — #2209 again, with a narrower trigger and the same silence.
|
||||
"""
|
||||
assert manifest_differs_beyond_version(manifest(**{field: value}), manifest()) is True
|
||||
|
||||
|
||||
def test_reformatting_is_not_a_content_change():
|
||||
"""Parsed objects, not text. Whitespace and key order are not content, and
|
||||
a check that treated them as such would demand a version for a re-indent."""
|
||||
data = json.loads(manifest())
|
||||
reordered = {k: data[k] for k in reversed(list(data))}
|
||||
assert manifest_differs_beyond_version(
|
||||
json.dumps(reordered, indent=4), json.dumps(data, separators=(",", ":"))
|
||||
) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "{not json", "[]", '"a string"', "null"])
|
||||
def test_unreadable_input_demands_a_new_version(bad):
|
||||
"""The conservative direction, chosen deliberately.
|
||||
|
||||
A spurious bump costs one cache refresh. A missed one is #2209 — the fix
|
||||
reaches the repo and stops there, and the only detector is a human saying
|
||||
"I don't think it updated."
|
||||
"""
|
||||
assert manifest_differs_beyond_version(bad, manifest()) is True
|
||||
assert manifest_differs_beyond_version(manifest(), bad) is True
|
||||
|
||||
|
||||
def test_a_manifest_appearing_or_vanishing_is_a_change():
|
||||
"""None means the file is absent at that ref — a real difference, and not
|
||||
the same thing as unreadable."""
|
||||
assert manifest_differs_beyond_version(None, manifest()) is True
|
||||
assert manifest_differs_beyond_version(manifest(), None) is True
|
||||
|
||||
|
||||
# ── The reader that joins the exclusion to git ─────────────────────────────
|
||||
|
||||
|
||||
def test_shipped_content_changed_reports_a_version_only_commit_as_unchanged(monkeypatch):
|
||||
"""End to end through the git seam, with git stubbed.
|
||||
|
||||
The unit above proves the comparison; this proves it is actually WIRED to
|
||||
the path that `check_version_is_minted` reads. A correct helper nobody calls
|
||||
would leave the circular check exactly as it was.
|
||||
"""
|
||||
from scripts import check_plugin
|
||||
|
||||
monkeypatch.setattr(
|
||||
check_plugin, "_git",
|
||||
lambda *a: (0, "plugin/.claude-plugin/plugin.json"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
check_plugin, "manifest_text",
|
||||
lambda ref=None: manifest(version="2026.09.01.0512" if ref is None else "0.1.48"),
|
||||
)
|
||||
changed, paths = check_plugin.shipped_content_changed("origin/main")
|
||||
assert changed is False
|
||||
assert paths == ["plugin/.claude-plugin/plugin.json"]
|
||||
|
||||
|
||||
def test_shipped_content_changed_reports_a_hook_edit_as_changed(monkeypatch):
|
||||
"""The guard against an exclusion that swallowed everything — a check that
|
||||
can never fire is indistinguishable from one that is broken."""
|
||||
from scripts import check_plugin
|
||||
|
||||
monkeypatch.setattr(
|
||||
check_plugin, "_git",
|
||||
lambda *a: (0, "plugin/hooks/scribe_session_context.sh"),
|
||||
)
|
||||
changed, paths = check_plugin.shipped_content_changed("origin/main")
|
||||
assert changed is True
|
||||
assert paths == ["plugin/hooks/scribe_session_context.sh"]
|
||||
|
||||
|
||||
def test_a_failed_diff_is_None_and_never_False(monkeypatch):
|
||||
"""Could-not-tell and nothing-changed must not collapse into one value.
|
||||
|
||||
#2663 is the precedent: a read that failed inside a broad except reported
|
||||
the same zero as a genuinely empty window, and every counter read zero for
|
||||
weeks with nothing to distinguish the two.
|
||||
"""
|
||||
from scripts import check_plugin
|
||||
|
||||
monkeypatch.setattr(check_plugin, "_git", lambda *a: (128, "fatal: bad revision"))
|
||||
changed, paths = check_plugin.shipped_content_changed("origin/main")
|
||||
assert changed is None
|
||||
assert paths == []
|
||||
@@ -0,0 +1,287 @@
|
||||
"""The plugin's version is MINTED, and CI is the control that it moved.
|
||||
|
||||
WHAT THIS IS ABOUT (milestone 334 step 3). `plugin/` ships straight from this
|
||||
git repo — no build step, so no moment at which CI could stamp a version in.
|
||||
The value is therefore minted by a script before the commit, and CI's job is
|
||||
not to produce it but to prove it moved when it had to.
|
||||
|
||||
THE DIVERGENCE THESE GUARD. Two artifacts in one repo derive their versions
|
||||
from different clocks, on purpose:
|
||||
|
||||
server image name from COMMIT time, ordering key from BUILD time
|
||||
plugin one value, from MINT time
|
||||
|
||||
#3127 §2 prescribes commit time so two lanes building one source report one
|
||||
string. The plugin has one lane and no build, so that reason does not reach
|
||||
it. "Let's make these consistent" is the obvious tidy-up and it breaks
|
||||
whichever artifact loses — which is why the difference is pinned here rather
|
||||
than only explained in a comment.
|
||||
|
||||
The trade mint time makes — you cannot recompute the value from history, only
|
||||
verify it moved — is acceptable ONLY because #3325 established that the
|
||||
installer's refresh test is `===` with no ordering anywhere. Where a
|
||||
comparator orders, an unreproducible version would be unverifiable too.
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import check_plugin
|
||||
from scripts.mint_plugin_version import VERSION_RE, mint, rewrite
|
||||
|
||||
MINT_SRC = pathlib.Path(check_plugin.ROOT) / "scripts" / "mint_plugin_version.py"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_failures():
|
||||
"""`check_plugin.fail` appends to a module global; without this a failing
|
||||
assertion in one test would be visible from the next."""
|
||||
check_plugin.failures.clear()
|
||||
yield
|
||||
check_plugin.failures.clear()
|
||||
|
||||
|
||||
def fake_manifest(version: str = "2026.09.01.2252") -> str:
|
||||
return json.dumps(
|
||||
{"name": "scribe", "description": "d", "version": version,
|
||||
"userConfig": {"api_endpoint": {"type": "string"}}},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
|
||||
# ── The mint ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("when,expected", [
|
||||
# THE midnight case, which #3127 checklist 10 names by hand. An unpadded
|
||||
# `%-H%M` renders this hour as `0` and silently shortens the string.
|
||||
(datetime(2026, 1, 5, 0, 0, tzinfo=timezone.utc), "2026.01.05.0000"),
|
||||
(datetime(2026, 1, 5, 0, 9, tzinfo=timezone.utc), "2026.01.05.0009"),
|
||||
(datetime(2026, 12, 31, 23, 59, tzinfo=timezone.utc), "2026.12.31.2359"),
|
||||
(datetime(2026, 9, 1, 22, 52, tzinfo=timezone.utc), "2026.09.01.2252"),
|
||||
])
|
||||
def test_the_mint_zero_pads_every_field(when, expected):
|
||||
assert mint(when) == expected
|
||||
assert VERSION_RE.match(mint(when))
|
||||
|
||||
|
||||
def test_the_mint_is_UTC_not_local():
|
||||
"""A local-time mint would make the value depend on who ran it — two people
|
||||
minting the same minute would disagree, and the string is the artifact's
|
||||
identity."""
|
||||
utc = datetime(2026, 9, 1, 22, 52, tzinfo=timezone.utc)
|
||||
east = utc.astimezone(timezone(timedelta(hours=9)))
|
||||
assert mint(east) == mint(utc) == "2026.09.01.2252"
|
||||
|
||||
|
||||
def test_the_mint_reads_a_CLOCK_and_never_git():
|
||||
"""The clock divergence from the server image, asserted structurally.
|
||||
|
||||
Mint time is only meaningful if nothing consults history — the moment this
|
||||
script shells out to git it has quietly become a commit-time deriver, and
|
||||
the two artifacts' clocks have been "made consistent" without anyone
|
||||
deciding to. That change would pass every other test in this file.
|
||||
|
||||
Asserted over the AST rather than the text, because the module docstring
|
||||
discusses git at length explaining why it is absent. This looks for USE,
|
||||
not mention.
|
||||
"""
|
||||
tree = ast.parse(MINT_SRC.read_text())
|
||||
|
||||
imported = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
imported |= {a.name.split(".")[0] for a in node.names}
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
imported.add(node.module.split(".")[0])
|
||||
assert "subprocess" not in imported, (
|
||||
"the mint script imports subprocess — a mint that can read history is "
|
||||
"a commit-time deriver wearing the wrong name"
|
||||
)
|
||||
|
||||
called = {ast.unparse(n.func) for n in ast.walk(tree) if isinstance(n, ast.Call)}
|
||||
assert "datetime.now" in called, "the mint script no longer reads a clock"
|
||||
|
||||
|
||||
# ── The rewrite ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_rewrite_touches_exactly_one_line():
|
||||
"""Surgical, not a JSON round-trip. The manifest's formatting and key order
|
||||
are not this script's to decide, and a whole-file reformat would make every
|
||||
mint an unreadable diff."""
|
||||
before = fake_manifest("0.1.48")
|
||||
after = rewrite(before, "2026.09.01.2252")
|
||||
|
||||
b, a = before.splitlines(), after.splitlines()
|
||||
assert len(b) == len(a)
|
||||
differing = [i for i, (x, y) in enumerate(zip(b, a)) if x != y]
|
||||
assert len(differing) == 1
|
||||
assert '"version": "2026.09.01.2252"' in a[differing[0]]
|
||||
|
||||
|
||||
def test_the_rewrite_preserves_indentation_and_key_order():
|
||||
weird = '{\n\t"name": "scribe",\n\t"version": "0.1.48",\n\t"z": 1\n}\n'
|
||||
out = rewrite(weird, "2026.09.01.2252")
|
||||
assert out == '{\n\t"name": "scribe",\n\t"version": "2026.09.01.2252",\n\t"z": 1\n}\n'
|
||||
|
||||
|
||||
def test_the_rewrite_refuses_a_manifest_it_cannot_match():
|
||||
"""Raises rather than falling back to a JSON round-trip: a manifest this
|
||||
cannot match is one whose shape changed, and quietly reformatting the file
|
||||
to cope would be a far larger edit than the caller asked for."""
|
||||
with pytest.raises(ValueError):
|
||||
rewrite('{"name": "scribe"}', "2026.09.01.2252")
|
||||
|
||||
|
||||
def test_the_rewrite_refuses_TWO_version_lines():
|
||||
"""A capped `subn` would report one replacement and look clean while the
|
||||
second `version` — possibly the real one — kept its old value."""
|
||||
two = '{\n "version": "0.1.48",\n "nested": {\n "version": "9.9.9"\n }\n}\n'
|
||||
with pytest.raises(ValueError):
|
||||
rewrite(two, "2026.09.01.2252")
|
||||
|
||||
|
||||
# ── The version-relevant set includes its own deriver ──────────────────────
|
||||
|
||||
|
||||
def test_the_mint_script_is_version_relevant():
|
||||
"""#3127 §3's asymmetry. A change to how the version is COMPUTED is
|
||||
compared against nothing — leave the deriver out of the set and a format
|
||||
change never forces a re-mint, so the manifest keeps a value in the old
|
||||
format indefinitely and nothing says so."""
|
||||
paths = check_plugin.version_relevant_paths()
|
||||
assert "scripts/mint_plugin_version.py" in paths
|
||||
for shipped in check_plugin.SHIPPED_PATHS:
|
||||
assert shipped in paths
|
||||
|
||||
|
||||
def test_the_checker_is_NOT_version_relevant():
|
||||
"""The inverse, and it is the easy mistake. A checker decides whether the
|
||||
lane goes red, not what the artifact reports — so its absence here is a
|
||||
decision, not an oversight."""
|
||||
assert "scripts/check_plugin.py" not in check_plugin.version_relevant_paths()
|
||||
|
||||
|
||||
# ── The check ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_check(here: str, there: str | None, changed_paths: list[str], monkeypatch):
|
||||
"""Drive `check_version_is_minted` with git stubbed. Returns the failures."""
|
||||
monkeypatch.setattr(
|
||||
check_plugin, "_git",
|
||||
lambda *a: (0, "\n".join(changed_paths)) if a[0] == "diff" else (0, ""),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
check_plugin, "manifest_version",
|
||||
lambda ref=None: here if ref is None else there,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
check_plugin, "manifest_text",
|
||||
lambda ref=None: fake_manifest(here if ref is None else (there or "0.0.0.0000")),
|
||||
)
|
||||
check_plugin.check_version_is_minted("origin/main")
|
||||
return list(check_plugin.failures)
|
||||
|
||||
|
||||
def test_content_changed_and_the_version_did_not_FAILS(monkeypatch):
|
||||
"""#2209, exactly. The headline, and the only reason the check exists."""
|
||||
failures = run_check(
|
||||
"2026.09.01.2252", "2026.09.01.2252",
|
||||
["plugin/hooks/scribe_session_context.sh"], monkeypatch,
|
||||
)
|
||||
assert len(failures) == 1
|
||||
assert "still 2026.09.01.2252" in failures[0]
|
||||
assert "scribe_session_context.sh" in failures[0]
|
||||
|
||||
|
||||
def test_content_changed_and_the_version_moved_PASSES(monkeypatch):
|
||||
assert run_check(
|
||||
"2026.09.01.2252", "2026.08.30.1200",
|
||||
["plugin/hooks/scribe_session_context.sh"], monkeypatch,
|
||||
) == []
|
||||
|
||||
|
||||
def test_a_version_that_is_not_the_canonical_shape_FAILS(monkeypatch):
|
||||
"""`K4` returns the manifest string verbatim, so a malformed value is not
|
||||
rejected by the installer — it either sorts as an ordinary string or, when
|
||||
unreadable, forces a reinstall every session. Neither is loud (#3325)."""
|
||||
failures = run_check("0.1.48", "0.1.47", [], monkeypatch)
|
||||
assert len(failures) == 1
|
||||
assert "not YYYY.MM.DD.HHMM" in failures[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["2026.9.1.2252", "2026.09.01.252", "2026.09.01"])
|
||||
def test_an_UNPADDED_or_short_version_FAILS(bad, monkeypatch):
|
||||
"""The padding is the contract, not cosmetics — one shape for every version
|
||||
in the family (#3127 checklist 10)."""
|
||||
assert run_check(bad, "2026.08.30.1200", [], monkeypatch) != []
|
||||
|
||||
|
||||
def test_a_version_in_the_future_FAILS(monkeypatch):
|
||||
ahead = (datetime.now(timezone.utc) + timedelta(days=400)).strftime("%Y.%m.%d.%H%M")
|
||||
failures = run_check(ahead, "2026.08.30.1200", [], monkeypatch)
|
||||
assert len(failures) == 1
|
||||
assert "in the future" in failures[0]
|
||||
|
||||
|
||||
def test_a_version_minted_minutes_ago_is_NOT_in_the_future(monkeypatch):
|
||||
"""The guard has to tolerate ordinary skew: the mint happens on a
|
||||
workstation and the lane runs later, on another machine's clock."""
|
||||
now = datetime.now(timezone.utc).strftime("%Y.%m.%d.%H%M")
|
||||
assert run_check(now, "2026.08.30.1200", [], monkeypatch) == []
|
||||
|
||||
|
||||
def test_nothing_changed_and_nothing_minted_PASSES(monkeypatch):
|
||||
assert run_check("2026.09.01.2252", "2026.09.01.2252", [], monkeypatch) == []
|
||||
|
||||
|
||||
def test_a_version_that_moved_with_no_content_change_is_NOT_a_failure(monkeypatch):
|
||||
"""Deliberately a pass. A needless re-mint costs one cache refresh; failing
|
||||
the lane over a harmless act is how a check earns a `--no-version` in
|
||||
somebody's muscle memory and stops running at all. The implication that
|
||||
matters is one-directional: content changed IMPLIES version moved."""
|
||||
assert run_check("2026.09.01.2252", "2026.08.30.1200", [], monkeypatch) == []
|
||||
|
||||
|
||||
def test_a_failed_diff_FAILS_rather_than_passing_quietly(monkeypatch):
|
||||
"""A check that cannot run must not report the same thing as a check that
|
||||
passed — #2663's lesson, and the reason this file's siblings exist.
|
||||
|
||||
`rev-parse` is stubbed to SUCCEED so only the diff fails. Failing every git
|
||||
call would trip the base-branch guard first and this would pass while
|
||||
proving nothing about the diff arm.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
check_plugin, "_git",
|
||||
lambda *a: (128, "fatal: bad object") if a[0] == "diff" else (0, ""),
|
||||
)
|
||||
monkeypatch.setattr(check_plugin, "manifest_version",
|
||||
lambda ref=None: "2026.09.01.2252")
|
||||
check_plugin.check_version_is_minted("origin/main")
|
||||
assert len(check_plugin.failures) == 1
|
||||
assert "git diff" in check_plugin.failures[0]
|
||||
|
||||
|
||||
# ── The real manifest ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_shipped_manifest_carries_a_minted_version():
|
||||
"""The end of the hand-bumped scheme, asserted on the real file. `0.1.48`
|
||||
was the last of 48 numbers a person typed."""
|
||||
version = json.loads(check_plugin.MANIFEST.read_text())["version"]
|
||||
assert VERSION_RE.match(version), (
|
||||
f"the shipped manifest says {version!r}, which is not a minted version"
|
||||
)
|
||||
|
||||
|
||||
def test_the_session_context_hook_still_reads_the_version_field():
|
||||
"""The marker #2220 asked for. The value's SHAPE changed, not the field or
|
||||
its reader — if this had to move, the derivation went somewhere it should
|
||||
not have."""
|
||||
hook = (check_plugin.HOOKS_DIR / "scribe_session_context.sh").read_text()
|
||||
assert re.search(r"jq\s+-r\s+'\.version", hook)
|
||||
@@ -122,3 +122,27 @@ def test_rule_and_subscription_handlers_callable():
|
||||
"relate_rules", "unrelate_rules",
|
||||
):
|
||||
assert callable(getattr(rb_routes, name))
|
||||
|
||||
|
||||
def test_the_rule_list_zero_fills_usage_on_every_row():
|
||||
"""Milestone 333 step 5, asserted the only way this harness allows.
|
||||
|
||||
There is no live-HTTP fixture here (see this module's docstring), so this
|
||||
reads the handler's source. What it can still prove is the property that
|
||||
gets forgotten: the route must attach the key to EVERY row, zero-filled,
|
||||
rather than only to rows that happen to have events. Every rule on every
|
||||
existing install predates `rule_usage_events`, so a route that only
|
||||
attached the key when it found something would leave the badge component
|
||||
reading `undefined` on almost every row — and the difference between "no
|
||||
events" and "no field" is exactly the distinction #2663 is about.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from scribe.routes import rulebooks as rb_routes
|
||||
|
||||
src = inspect.getsource(rb_routes.list_rules)
|
||||
assert "usage_for_rules" in src, "the rule list does not read usage at all"
|
||||
assert "empty_rule_usage()" in src, (
|
||||
"the rule list does not zero-fill — a rule with no events would come "
|
||||
"back without the key rather than with an empty one"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Both rule-creation tools run the propose-then-approve loop (#3557).
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
Every other gate on `create_rule` and `create_project_rule` is about SHAPE:
|
||||
is this a rule or a process, is it one thing you could violate, is it general
|
||||
enough for a rulebook, is it a near-duplicate. All of those improve a rule
|
||||
someone has already decided to write. None of them asks the prior question —
|
||||
whether the person the rule will bind has agreed to be bound by it.
|
||||
|
||||
That question belongs at the tool, because the tool is the last surface a
|
||||
caller reads before the write, and because the write is less reversible than
|
||||
it looks. The operator's yes is not merely consent; it is the one moment the
|
||||
rule is certainly IN FRONT of them. Afterwards it may not be again for
|
||||
months: a conditional rule is not read aloud at session start, and a
|
||||
project-scoped rule does not appear in an unfiltered `list_rules()` at all.
|
||||
The proposal IS the review, so there had better be one.
|
||||
|
||||
WHY IT IS PHRASED AS A PRACTICE AND NOT A PROHIBITION
|
||||
|
||||
The first cut of this guidance opened "NOT YOURS TO CALL UNPROMPTED." That is
|
||||
the wrong instrument, and the failure it invites is worse than the one it
|
||||
prevents: a caller reading a prohibition stops NOTICING rule-shaped things,
|
||||
rather than noticing them and asking. The wanted behaviour is more proposals,
|
||||
not fewer — spotting that something has hardened into a standing instruction
|
||||
is valuable work, and the only step that was ever missing came after it.
|
||||
|
||||
So the docstrings describe what to DO: propose readily, state four things,
|
||||
close with a question the operator answers in one word. This test is written
|
||||
the same way — it asserts the parts of the loop are present, and has nothing
|
||||
to say about any wording that forbids.
|
||||
|
||||
The fourth element — how the rule would be ENFORCED — is not ceremony. It is
|
||||
the part that sometimes dissolves the rule: a thing a test can assert should
|
||||
be that test, and a rule is what is left when nothing mechanical can hold it.
|
||||
A rulebook grows by default and shrinks only on purpose, so the question that
|
||||
prevents a rule earns more than any question that improves one's wording.
|
||||
|
||||
WHAT THIS PINS, AND WHAT IT DOES NOT
|
||||
|
||||
STRUCTURE, never wording — the same bargain the disambiguator guard (#3123)
|
||||
strikes next door. Each element matches a family of synonyms, so the prose
|
||||
stays free to be rewritten, reordered or sharpened; only DELETING one fails.
|
||||
Pinning phrasing would make every improvement a red build, and a test that
|
||||
punishes editing is a test someone deletes.
|
||||
|
||||
It cannot tell whether an agent actually proposes. Nothing in a docstring
|
||||
can. It catches the regression that really happens: guidance tidied away in
|
||||
a later pass by someone who read it as throat-clearing in front of the Args.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from tests.helpers import tool_doc as _doc
|
||||
|
||||
# Both surfaces, because the one that needs it most is the one that looks
|
||||
# minor. A project rule is the least visible record the system can hold —
|
||||
# absent from an unfiltered list_rules(), and absent from session start too
|
||||
# whenever it is conditional — so the surface that writes one carries the
|
||||
# larger risk while reading as the smaller act.
|
||||
_SURFACES = [
|
||||
("scribe.mcp.tools.rulebooks", "create_rule"),
|
||||
("scribe.mcp.tools.rulebooks", "create_project_rule"),
|
||||
]
|
||||
|
||||
# The loop, element by element, each as a family of ways to say it. A
|
||||
# docstring satisfies an element by containing ANY member — that is the room
|
||||
# left for rewriting. The families deliberately exclude bare words a
|
||||
# docstring would hold by accident ("why", "how", "reason", "rule"), which
|
||||
# would let the assertion pass on prose that says nothing of the kind.
|
||||
_ELEMENTS = {
|
||||
"the invitation to propose": ("propose", "proposal"),
|
||||
"the operator's approval": ("approve", "approval", "says yes", "a yes"),
|
||||
"the rule's intent": ("intent", "what it changes about how work"),
|
||||
"why it is being proposed now": (
|
||||
"why now", "arose_from_id", "the incident", "prompted it",
|
||||
),
|
||||
"how it would be enforced": ("enforc",),
|
||||
"the answers offered back": ("as written", "talk about it", "discuss"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("module", "name"), _SURFACES)
|
||||
@pytest.mark.parametrize("element", sorted(_ELEMENTS))
|
||||
def test_a_rule_surface_carries_every_part_of_the_proposal_loop(
|
||||
module, name, element
|
||||
):
|
||||
"""Each element of propose → state four things → ask survives."""
|
||||
doc = _doc(module, name).lower()
|
||||
assert any(token in doc for token in _ELEMENTS[element]), (
|
||||
f"{name}'s docstring no longer mentions {element}. A caller reads "
|
||||
f"this immediately before writing a rule that will bind every future "
|
||||
f"session, and the proposal is the one moment that rule is certain to "
|
||||
f"be seen by the operator. Say it in whatever words you like; this "
|
||||
f"guard only checks it is still said. See create_rule's opening."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("module", "name"), _SURFACES)
|
||||
def test_the_proposal_loop_comes_before_the_parameter_contract(module, name):
|
||||
"""It has to be read to work, and the Args: block is where reading stops.
|
||||
|
||||
A caller who has decided to make the call skims down to the parameters.
|
||||
Guidance parked below them — or folded into one argument's description —
|
||||
arrives after the decision it was meant to inform, which is the same as
|
||||
not being there.
|
||||
"""
|
||||
doc = _doc(module, name).lower()
|
||||
args_at = doc.find("args:")
|
||||
assert args_at > 0, f"{name}'s docstring has no Args: block"
|
||||
loop_at = min(
|
||||
(doc.find(t) for t in _ELEMENTS["the invitation to propose"]
|
||||
if doc.find(t) >= 0),
|
||||
default=-1,
|
||||
)
|
||||
assert 0 <= loop_at < args_at, (
|
||||
f"{name} introduces the proposal loop at or after its Args: block "
|
||||
f"(loop {loop_at}, args {args_at}). Move it to the opening — a "
|
||||
f"caller who has already decided to write the rule reads the "
|
||||
f"parameters, not the prose under them."
|
||||
)
|
||||
@@ -0,0 +1,868 @@
|
||||
"""Both ends of the rule-usage loop are actually wired (milestone 333 step 2).
|
||||
|
||||
Step 1 built the table and the service. A counter nobody calls reads zero and
|
||||
looks exactly like a surface nobody uses — which is #2663's shape and the whole
|
||||
reason this milestone exists. So this file is about the CALL SITES, not the
|
||||
storage.
|
||||
|
||||
Cross-cutting on purpose: the surfaced end lives in `plugin_context`, the pull
|
||||
end in two different doors, and the property under test is that they meet. Split
|
||||
across three module-shaped files, "both ends are wired" is a thing no single
|
||||
test asserts.
|
||||
"""
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers import fake_note, fake_rule
|
||||
|
||||
# The MCP tool layer reads its caller from a ContextVar the HTTP transport sets
|
||||
# per request; a unit test has no request, so it binds the caller itself. The
|
||||
# arm tests do not need it — build_write_path_hint takes user_id directly — but
|
||||
# the module-level mark is how every tool-layer test file in this repo opts in.
|
||||
pytestmark = pytest.mark.usefixtures("_bind_user")
|
||||
|
||||
|
||||
# ── The surfaced end ───────────────────────────────────────────────────
|
||||
#
|
||||
# conftest's autouse `_no_rule_arm` stubs `semantic_search_rules` so unrelated
|
||||
# plugin-context tests don't pull a real embedding model through this arm. Its
|
||||
# docstring says a test that wants the arm live can re-patch it — that is what
|
||||
# each of these does.
|
||||
|
||||
|
||||
# The write-path hint returns early when a write matched nothing at all — no
|
||||
# staleness, no synced record, no prior-art menu, no shape signal. The rule arm
|
||||
# sits deliberately on the FAR side of that guard, because it runs a semantic
|
||||
# search and moving it above would mean an embedding query on every write in
|
||||
# the session (#3311's closing note, and the reason its gating is a separate
|
||||
# question from precision).
|
||||
#
|
||||
# So a fixture that stubs every other arm to empty never reaches the rule arm
|
||||
# at all — which is what the first run of this file did. The note hit below is
|
||||
# not decoration: it is the condition the arm requires in order to fire.
|
||||
_PRIOR_ART = [(0.72, fake_note(id=9, title="debounce helper", user_id=1,
|
||||
note_type="snippet"))]
|
||||
|
||||
|
||||
def _arm_patches(pc, hits, recorder, prior_art=None, cfg=None, rule_search=None,
|
||||
retrieval_log=None):
|
||||
"""The minimum stubbing that lets the rule arm run and nothing else.
|
||||
|
||||
`cfg`, `rule_search` and `retrieval_log` are overridable so a caller can
|
||||
inspect what the arm ASKED for, and what it told the CALL log, rather than
|
||||
only what it did with the answer — patching them a second time on top would
|
||||
work, but reads as an accident.
|
||||
"""
|
||||
return (
|
||||
patch.object(pc, "get_writepath_config",
|
||||
AsyncMock(return_value=cfg or {
|
||||
"enabled": True, "threshold": 0.6,
|
||||
"top_k": 3, "rule_threshold": 0.6,
|
||||
})),
|
||||
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))),
|
||||
patch.object(pc, "semantic_search_notes",
|
||||
AsyncMock(return_value=_PRIOR_ART if prior_art is None
|
||||
else prior_art)),
|
||||
patch.object(pc, "semantic_search_rules",
|
||||
rule_search or AsyncMock(return_value=hits)),
|
||||
patch.object(pc, "record_retrieval", retrieval_log or MagicMock()),
|
||||
patch.object(pc, "record_surfaced", MagicMock()),
|
||||
patch.object(pc, "record_rule_surfaced", recorder),
|
||||
patch.object(pc, "owner_names_for", AsyncMock(return_value={})),
|
||||
patch.object(pc, "concept_query", MagicMock(return_value="a deadline on a fetch")),
|
||||
)
|
||||
|
||||
|
||||
async def _run_arm(hits, recorder, prior_art=None, retrieval_log=None, **kwargs):
|
||||
from scribe.services import plugin_context as pc
|
||||
with ExitStack() as stack:
|
||||
for ctx in _arm_patches(pc, hits, recorder, prior_art,
|
||||
retrieval_log=retrieval_log):
|
||||
stack.enter_context(ctx)
|
||||
return await pc.build_write_path_hint(
|
||||
1, "frontend/src/api/client.ts", code="x" * 400, **kwargs
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_arm_records_what_it_showed():
|
||||
"""The claim being measured. Without this call the arm keeps producing
|
||||
scores in retrieval_logs and no evidence that any hint was ever read."""
|
||||
rec = MagicMock()
|
||||
hits = [(0.71, fake_rule(id=156, title="A wait with no deadline is a bug"))]
|
||||
await _run_arm(hits, rec)
|
||||
|
||||
assert rec.call_count == 1
|
||||
kw = rec.call_args.kwargs
|
||||
assert kw["rule_ids"] == [156]
|
||||
assert kw["source"] == "write_path_rule"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_rule_the_session_already_holds_is_not_counted_as_surfaced():
|
||||
"""`exclude_rule_ids` drops what the session already has, and the recorded
|
||||
set must be what was SHOWN, not what was considered.
|
||||
|
||||
Counting the excluded ones would inflate the denominator with claims the
|
||||
agent never saw — the ratio would fall for a reason that has nothing to do
|
||||
with whether the hints landed, which is precisely the misreading this
|
||||
milestone exists to prevent.
|
||||
"""
|
||||
rec = MagicMock()
|
||||
hits = [
|
||||
(0.71, fake_rule(id=156, title="A wait with no deadline is a bug")),
|
||||
(0.70, fake_rule(id=157, title="A loop re-arms in a finally")),
|
||||
]
|
||||
await _run_arm(hits, rec, exclude_rule_ids=[157])
|
||||
|
||||
assert rec.call_args.kwargs["rule_ids"] == [156]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nothing_is_recorded_when_every_hit_was_already_held():
|
||||
"""No surfacing happened, so no surfacing is recorded. A zero-row batch
|
||||
would still be a call, and a call that says "we showed nothing" pollutes
|
||||
the count of times the arm spoke."""
|
||||
rec = MagicMock()
|
||||
hits = [(0.71, fake_rule(id=156, title="A wait with no deadline is a bug"))]
|
||||
await _run_arm(hits, rec, exclude_rule_ids=[156])
|
||||
|
||||
assert rec.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_arm_searches_on_its_OWN_bar_not_the_code_one():
|
||||
"""The consuming half of step 4. `get_writepath_config` assembling a
|
||||
separate `rule_threshold` means nothing if the arm still passes
|
||||
`cfg["threshold"]` to its search — the split would exist in the config and
|
||||
not in the behaviour, and #3311 would be exactly where it was.
|
||||
|
||||
The two values are deliberately different here so the assertion can tell
|
||||
them apart.
|
||||
"""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
search = AsyncMock(return_value=[])
|
||||
with ExitStack() as stack:
|
||||
for ctx in _arm_patches(
|
||||
pc, [], MagicMock(), rule_search=search,
|
||||
cfg={"enabled": True, "threshold": 0.60,
|
||||
"top_k": 3, "rule_threshold": 0.81},
|
||||
):
|
||||
stack.enter_context(ctx)
|
||||
await pc.build_write_path_hint(
|
||||
1, "frontend/src/api/client.ts", code="x" * 400,
|
||||
)
|
||||
|
||||
kw = search.await_args.kwargs
|
||||
assert kw["threshold"] == 0.81, "the arm is still using the code threshold"
|
||||
assert kw["limit"] == pc.RULEHINT_LIMIT
|
||||
# NO tier filter (#3702). The arms search every rule the caller owns,
|
||||
# because "already in the session" is not the same as "in front of the
|
||||
# reader at the moment it applies" — and relevance is the threshold's
|
||||
# job, not a category's. If this assertion is failing because a tier
|
||||
# argument came back, read the block above RULEHINT_LIMIT first: the
|
||||
# filter may legitimately return, but only carrying a measured reason.
|
||||
assert "tier" not in kw or kw["tier"] is None, (
|
||||
"the arm is filtering the rule corpus by tier again"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_arm_does_not_fire_on_a_write_that_matched_nothing():
|
||||
"""The gate, pinned — because the fixture above now depends on it and a
|
||||
silent change would make every other test here pass vacuously.
|
||||
|
||||
A write matching no prior art returns before the rule arm runs. That is
|
||||
deliberate: the arm is a semantic search, and ungating it means an
|
||||
embedding query on every write in the session. #3311 is explicit that the
|
||||
gate stays until the arm's precision is fixed, so this failing is a signal
|
||||
to go read that issue rather than to update the assertion.
|
||||
"""
|
||||
rec = MagicMock()
|
||||
hits = [(0.71, fake_rule(id=156, title="A wait with no deadline is a bug"))]
|
||||
await _run_arm(hits, rec, prior_art=[])
|
||||
|
||||
assert rec.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failing_recorder_does_not_break_the_write():
|
||||
"""Telemetry must never take down the surface it observes. The arm is
|
||||
already wrapped in a fail-open try/except; this pins that the new call is
|
||||
INSIDE it rather than after."""
|
||||
rec = MagicMock(side_effect=RuntimeError("telemetry is down"))
|
||||
hits = [(0.71, fake_rule(id=156, title="A wait with no deadline is a bug"))]
|
||||
out = await _run_arm(hits, rec)
|
||||
|
||||
assert "context" in out
|
||||
|
||||
|
||||
# ── The pull end ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_get_rule_records_an_agent_pull():
|
||||
"""THE pull that matters: the arm's own message ends "Read it with
|
||||
get_rule(N)", so this is the exact action a landed hint produces."""
|
||||
rec = MagicMock()
|
||||
rule = fake_rule(id=156, title="A wait with no deadline is a bug")
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
|
||||
AsyncMock(return_value=rule)), \
|
||||
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.rule_detail",
|
||||
AsyncMock(return_value={"id": 156})), \
|
||||
patch("scribe.mcp.tools.rulebooks.record_rule_pulled", rec):
|
||||
from scribe.mcp.tools.rulebooks import get_rule
|
||||
await get_rule(rule_id=156)
|
||||
|
||||
assert rec.call_args.kwargs["rule_id"] == 156
|
||||
assert rec.call_args.kwargs["source"] == "mcp_get_rule"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_rule_that_cannot_be_read_is_not_a_pull():
|
||||
"""Recorded after the access check. A refused read is not a pull, and
|
||||
counting it would credit the arm for a hint nobody could open."""
|
||||
rec = MagicMock()
|
||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
|
||||
AsyncMock(return_value=None)), \
|
||||
patch("scribe.mcp.tools.rulebooks.record_rule_pulled", rec):
|
||||
from scribe.mcp.tools.rulebooks import get_rule
|
||||
with pytest.raises(ValueError):
|
||||
await get_rule(rule_id=156)
|
||||
|
||||
assert rec.call_count == 0
|
||||
|
||||
|
||||
# ── Completeness: every door, and only the doors ───────────────────────
|
||||
|
||||
|
||||
def _source_of(module_path: str) -> str:
|
||||
return (Path(__file__).resolve().parents[1] / module_path).read_text()
|
||||
|
||||
|
||||
def test_every_rule_detail_door_records_a_pull():
|
||||
"""The task's own warning, made mechanical: miss a door and the ratio
|
||||
reads low for a reason that is not about the rules.
|
||||
|
||||
Source inspection rather than behaviour, because the REST door has no
|
||||
live-HTTP harness in the unit lane (see test_routes_rulebooks.py's own
|
||||
note). What it can still prove is that the handler names the recorder —
|
||||
which is the thing that gets forgotten when a door is added.
|
||||
"""
|
||||
rest = _source_of("src/scribe/routes/rulebooks.py")
|
||||
mcp = _source_of("src/scribe/mcp/tools/rulebooks.py")
|
||||
assert 'source="rest_rule"' in rest, (
|
||||
"the REST rule-detail route does not record a pull"
|
||||
)
|
||||
assert 'source="mcp_get_rule"' in mcp, (
|
||||
"the MCP get_rule tool does not record a pull"
|
||||
)
|
||||
|
||||
|
||||
def test_the_bulk_loaders_are_not_counted_as_pulls():
|
||||
"""`list_always_on_rules` and `enter_project` hand over every applicable
|
||||
rule at once. That is delivery, not somebody choosing to open one record,
|
||||
and counting it would swamp the signal with exactly the ambient surfacing
|
||||
the ratio exists to distinguish from.
|
||||
|
||||
Stated as a test because it is the tempting addition: both put rules in
|
||||
front of an agent, so "surely those are pulls too" is the reading someone
|
||||
arrives at without the argument.
|
||||
"""
|
||||
for path in ("src/scribe/mcp/tools/rulebooks.py",
|
||||
"src/scribe/mcp/tools/projects.py"):
|
||||
src = _source_of(path)
|
||||
for door in ("list_always_on_rules", "enter_project"):
|
||||
if f"async def {door}" not in src:
|
||||
continue
|
||||
body = src.split(f"async def {door}", 1)[1].split("\nasync def ", 1)[0]
|
||||
assert "record_rule_pulled" not in body, (
|
||||
f"{door} records a pull. It is a bulk resident load — every "
|
||||
"applicable rule at once — so counting it would drown the "
|
||||
"surfaced:pulled ratio in ambient delivery."
|
||||
)
|
||||
|
||||
|
||||
# ── The AMBIENT end: bulk deliveries (#3473) ───────────────────────────
|
||||
#
|
||||
# The preload was the largest rule surface in the product and emitted nothing,
|
||||
# so its cost was certain and its usefulness unfalsifiable. These assert the
|
||||
# three delivery shapes now emit — and, just as importantly, that the two
|
||||
# lookalike call sites which show nobody anything do NOT.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_session_start_preload_records_what_it_delivered():
|
||||
"""The block every session opens with. Chosen by nobody, paid for every
|
||||
turn — and until it emitted, invisible to the scoreboard that judges every
|
||||
other surface."""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
rec = MagicMock()
|
||||
rules = [fake_rule(id=1, title="`dev` is home"),
|
||||
fake_rule(id=2, title="`main` — never without explicit request")]
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch.object(pc.rulebooks_svc, "list_always_on_rules",
|
||||
AsyncMock(return_value=rules))
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object(pc.rulebooks_svc, "excluded_always_on_rulebooks",
|
||||
AsyncMock(return_value=[]))
|
||||
)
|
||||
stack.enter_context(patch.object(pc, "record_rule_surfaced", rec))
|
||||
stack.enter_context(
|
||||
patch.object(pc, "_topic_titles", AsyncMock(return_value={}))
|
||||
)
|
||||
await pc.build_session_context(1, project_id=0)
|
||||
|
||||
assert rec.call_count == 1, "the preload recorded nothing"
|
||||
kw = rec.call_args.kwargs
|
||||
assert kw["rule_ids"] == [1, 2]
|
||||
assert kw["source"] == "session_start"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_always_on_tool_records_what_it_handed_over():
|
||||
from scribe.mcp.tools import rulebooks as tools
|
||||
|
||||
rec = MagicMock()
|
||||
rules = [fake_rule(id=3, title="No GitHub — Fabled-Git only")]
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch.object(tools.rulebooks_svc, "list_always_on_rules",
|
||||
AsyncMock(return_value=rules))
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object(tools.rulebooks_svc, "rules_etag",
|
||||
MagicMock(return_value="etag"))
|
||||
)
|
||||
stack.enter_context(patch.object(tools, "record_rule_surfaced", rec))
|
||||
await tools.list_always_on_rules()
|
||||
|
||||
assert rec.call_args.kwargs["rule_ids"] == [3]
|
||||
assert rec.call_args.kwargs["source"] == "list_always_on_rules"
|
||||
|
||||
|
||||
def test_rules_payload_records_both_the_family_and_project_halves():
|
||||
"""One emit site for all five `rules_payload` surfaces.
|
||||
|
||||
Per-caller emission would be five sites to remember, and #3430 gap 2 is
|
||||
what that costs: the process→skill sync went un-emitted through an entire
|
||||
dedicated telemetry survey because nothing forced its surface to be
|
||||
accounted for.
|
||||
"""
|
||||
from scribe.services import rulebooks as svc
|
||||
|
||||
rec = MagicMock()
|
||||
with patch.object(svc, "record_rule_surfaced", rec):
|
||||
svc.rules_payload(
|
||||
{
|
||||
"rules": [{"id": 10}, {"id": 11}],
|
||||
"project_rules": [{"id": 12}],
|
||||
"truncated": False,
|
||||
"subscribed_rulebooks": [],
|
||||
},
|
||||
user_id=1,
|
||||
source="enter_project",
|
||||
)
|
||||
|
||||
kw = rec.call_args.kwargs
|
||||
assert kw["rule_ids"] == [10, 11, 12], "project-scoped rules were delivered too"
|
||||
assert kw["source"] == "enter_project"
|
||||
|
||||
|
||||
def test_every_rules_payload_caller_names_itself():
|
||||
"""`source` is the CALLER's name, so the readout can still separate the
|
||||
session handshake from a mid-session milestone read. A shared constant here
|
||||
would collapse five distinguishable surfaces into one."""
|
||||
import re
|
||||
|
||||
seen = set()
|
||||
for path in Path("src/scribe").rglob("*.py"):
|
||||
for m in re.finditer(r"rules_payload\([^)]*source=\"([a-z_]+)\"", path.read_text()):
|
||||
seen.add(m.group(1))
|
||||
assert seen == {
|
||||
"enter_project", "get_project", "get_milestone",
|
||||
"start_planning", "get_task",
|
||||
}, f"a rules_payload caller is missing or misnamed: {sorted(seen)}"
|
||||
|
||||
|
||||
def test_the_marker_paths_stay_silent():
|
||||
"""The two call sites that read the rules and show NOBODY anything.
|
||||
|
||||
`rules_etag_for` and the write-path staleness arm both call
|
||||
`list_always_on_rules` to build or compare a marker. Emitting there would
|
||||
put rules in the denominator that no agent ever saw — the exact inflation
|
||||
`record_rule_surfaced`'s docstring forbids, arriving from the one direction
|
||||
nothing else guards.
|
||||
"""
|
||||
svc_src = Path("src/scribe/services/rulebooks.py").read_text()
|
||||
etag_fn = svc_src.split("async def rules_etag_for")[1].split("\ndef ")[0]
|
||||
assert "record_rule_surfaced" not in etag_fn, (
|
||||
"rules_etag_for emits a surfacing — it builds a marker, it shows nothing"
|
||||
)
|
||||
|
||||
pc_src = Path("src/scribe/services/plugin_context.py").read_text()
|
||||
staleness = pc_src.split("if rules_etag:")[1].split("# The guard sits BELOW")[0]
|
||||
assert "record_rule_surfaced" not in staleness, (
|
||||
"the staleness arm emits a surfacing — it compares a marker, it shows nothing"
|
||||
)
|
||||
|
||||
|
||||
# ── The PRE-TOOL arm: rules keyed on the action (#3476) ────────────────
|
||||
#
|
||||
# The write-path arm can only be reached by a code write, so every rule about
|
||||
# which tool to reach for was unretrievable at the moment it mattered — which
|
||||
# is why they all had to be resident. These cover the surface that changes it.
|
||||
|
||||
|
||||
def _tool_patches(pc, hits, recorder, cfg=None, retrieval_log=None):
|
||||
return (
|
||||
patch.object(pc, "get_writepath_config",
|
||||
AsyncMock(return_value=cfg or {
|
||||
"enabled": True, "threshold": 0.6,
|
||||
"top_k": 3, "rule_threshold": 0.6,
|
||||
})),
|
||||
patch.object(pc, "semantic_search_rules", AsyncMock(return_value=hits)),
|
||||
patch.object(pc, "record_retrieval", retrieval_log or MagicMock()),
|
||||
patch.object(pc, "record_rule_surfaced", recorder),
|
||||
)
|
||||
|
||||
|
||||
async def _run_tool_arm(hits, recorder, command="curl -s https://git.example/api/v1/runs",
|
||||
tool="Bash", retrieval_log=None, **kwargs):
|
||||
from scribe.services import plugin_context as pc
|
||||
with ExitStack() as stack:
|
||||
for ctx in _tool_patches(pc, hits, recorder, retrieval_log=retrieval_log):
|
||||
stack.enter_context(ctx)
|
||||
return await pc.build_tool_rule_hint(1, tool, command, **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_tool_arm_names_a_rule_for_the_command_about_to_run():
|
||||
"""The 2026-09-03 incident in one test: reaching for curl against the forge
|
||||
API is a Bash call, and nothing watched Bash."""
|
||||
rec = MagicMock()
|
||||
hits = [(0.71, fake_rule(id=161,
|
||||
title="Reach the forge through its MCP tools, never curl",
|
||||
when_to_apply="whenever you need CI status"))]
|
||||
out = await _run_tool_arm(hits, rec)
|
||||
|
||||
assert out["rule_ids"] == [161]
|
||||
assert "Reach the forge through its MCP tools" in out["context"]
|
||||
assert "get_rule(161)" in out["context"], "the hint must hand over the way to read it"
|
||||
assert "Bash" in out["context"], "the hint names the tool it is about"
|
||||
assert rec.call_args.kwargs["source"] == "pre_tool_rule"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_tool_arm_is_a_ranked_source():
|
||||
"""It CHOSE what it showed, so a pull can settle whether the choice was any
|
||||
good — unlike a preload, which chose nothing. If this drifts into the
|
||||
ambient class the arm becomes unjudgeable, which is the state #3311
|
||||
described and M333 existed to end."""
|
||||
from scribe.services.rule_usage import is_ambient
|
||||
|
||||
assert not is_ambient("pre_tool_rule")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_rule_the_session_already_holds_is_not_re_offered():
|
||||
rec = MagicMock()
|
||||
hits = [(0.71, fake_rule(id=161, title="Reach the forge through its MCP tools")),
|
||||
(0.70, fake_rule(id=12, title="Don't run a local stack unless asked"))]
|
||||
out = await _run_tool_arm(hits, rec, exclude_rule_ids=[161])
|
||||
|
||||
assert out["rule_ids"] == [12]
|
||||
assert "161" not in out["context"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_empty_command_asks_the_ranker_nothing():
|
||||
"""Every Bash call reaches this. A blank payload must cost no embedding
|
||||
query at all, not merely return nothing after paying for one."""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
search = AsyncMock(return_value=[])
|
||||
rec = MagicMock()
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch.object(pc, "semantic_search_rules", search))
|
||||
stack.enter_context(patch.object(pc, "record_rule_surfaced", rec))
|
||||
out = await pc.build_tool_rule_hint(1, "Bash", " ")
|
||||
|
||||
assert out == {"context": "", "rule_ids": []}
|
||||
search.assert_not_called()
|
||||
rec.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_tool_arm_fails_open():
|
||||
"""A recall aid may never break the operator's action. A ranker that raises
|
||||
must cost the hint, not the command."""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch.object(pc, "get_writepath_config",
|
||||
AsyncMock(side_effect=RuntimeError("boom"))))
|
||||
out = await pc.build_tool_rule_hint(1, "Bash", "docker compose up -d")
|
||||
|
||||
assert out == {"context": "", "rule_ids": []}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_long_command_is_bounded_before_it_reaches_the_ranker():
|
||||
"""A heredoc or a pasted script would push the verb and its target — the
|
||||
part a rule is about — out of the embedding window."""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
search = AsyncMock(return_value=[])
|
||||
with ExitStack() as stack:
|
||||
for ctx in _tool_patches(pc, [], MagicMock()):
|
||||
stack.enter_context(ctx)
|
||||
stack.enter_context(patch.object(pc, "semantic_search_rules", search))
|
||||
await pc.build_tool_rule_hint(1, "Bash", "git tag v1 && " + "x" * 5000)
|
||||
|
||||
sent = search.call_args.args[1]
|
||||
assert len(sent) <= pc._TOOL_QUERY_CHARS
|
||||
assert sent.startswith("git tag v1"), "the head of the command is the signal"
|
||||
|
||||
|
||||
def test_the_two_pre_tool_arms_share_one_session_rule_ledger():
|
||||
"""The integration point most worth guarding.
|
||||
|
||||
Two ledgers would mean a rule named by the write arm gets re-offered by the
|
||||
tool arm — and the hint that fires most often is exactly the one that must
|
||||
not repeat itself. Asserted on the FILENAME both scripts build, because
|
||||
that is the shared thing; a copy of the path in each is how they drift.
|
||||
"""
|
||||
prior = Path("plugin/hooks/scribe_prior_art.sh").read_text()
|
||||
tool = Path("plugin/hooks/scribe_tool_rules.sh").read_text()
|
||||
|
||||
for src, name in ((prior, "scribe_prior_art.sh"), (tool, "scribe_tool_rules.sh")):
|
||||
assert '"${TMPDIR:-/tmp}/scribe-priorart"' in src, f"{name}: state dir moved"
|
||||
assert '.rules.ids' in src, f"{name}: rules ledger filename moved"
|
||||
assert "exclude_rule_ids" in src, f"{name}: does not send the exclusion"
|
||||
|
||||
|
||||
def test_the_tool_arm_is_registered_on_bash():
|
||||
"""A hook that exists and is not registered runs never — and reads exactly
|
||||
like a surface nobody needed."""
|
||||
import json
|
||||
|
||||
manifest = json.loads(Path("plugin/hooks/hooks.json").read_text())
|
||||
pre = manifest["hooks"]["PreToolUse"]
|
||||
entries = {
|
||||
m.get("matcher"): [h["command"] for h in m["hooks"]] for m in pre
|
||||
}
|
||||
assert "Bash" in entries, "nothing watches Bash — the reflex surface is unguarded"
|
||||
assert any("scribe_tool_rules.sh" in c for c in entries["Bash"])
|
||||
# The write arm keeps its own matcher; this is an addition, not a move.
|
||||
assert any("scribe_prior_art.sh" in c for c in entries["Write|Edit"])
|
||||
|
||||
|
||||
def test_the_hook_and_the_route_agree_on_every_parameter_name():
|
||||
"""Rule 33, on a brand-new integration between layers.
|
||||
|
||||
The hook is shell and the route is Python; nothing but this test connects
|
||||
them. A renamed query arg fails SILENTLY — the route reads an absent value,
|
||||
the arm quietly searches nothing, and the surface looks like one that never
|
||||
finds anything rather than one that is broken.
|
||||
"""
|
||||
import re
|
||||
|
||||
hook = Path("plugin/hooks/scribe_tool_rules.sh").read_text()
|
||||
route = Path("src/scribe/routes/plugin.py").read_text()
|
||||
handler = route.split("async def pre_tool_rules")[1].split("\n@plugin_bp")[0]
|
||||
|
||||
sent = set(re.findall(r"[?&]([a-z_]+)=", hook))
|
||||
assert sent == {"tool", "command", "repo", "exclude_rule_ids"}, sent
|
||||
|
||||
# `repo` is read by the shared _project_scope() helper, not inline.
|
||||
assert "_project_scope()" in handler
|
||||
for arg in ("tool", "command", "exclude_rule_ids"):
|
||||
assert f'request.args.get("{arg}")' in handler, (
|
||||
f"the hook sends {arg!r} and the route never reads it"
|
||||
)
|
||||
|
||||
|
||||
# ── The CALL log is unconditional; the SURFACING log is not (#3497) ────
|
||||
#
|
||||
# Both arms used to write their retrieval_logs row inside a guard on having
|
||||
# results, so `zero_result_calls` was pinned at 0 and `cleared_threshold` at
|
||||
# `calls` by the shape of the code — at any threshold whatsoever. #3311 read
|
||||
# that as a measurement of the corpus and a milestone was scoped on it.
|
||||
#
|
||||
# The distinction these tests hold: a CALL happened whether or not it found
|
||||
# anything, and the calls that found nothing are the only evidence a threshold
|
||||
# is set too high. A SURFACING did not happen when nothing was shown.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_write_path_arm_logs_the_call_that_found_nothing():
|
||||
log, rec = MagicMock(), MagicMock()
|
||||
await _run_arm([], rec, retrieval_log=log)
|
||||
|
||||
rule_calls = [c for c in log.call_args_list
|
||||
if c.kwargs.get("source") == "write_path_rule"]
|
||||
assert len(rule_calls) == 1, (
|
||||
"a rule call that found nothing wrote no row — `zero_result_calls` can "
|
||||
"then only ever read 0, however badly the threshold is tuned"
|
||||
)
|
||||
assert rule_calls[0].kwargs["results"] == []
|
||||
rec.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_write_path_arm_logs_a_call_whose_only_hit_was_already_shown():
|
||||
"""The subtler half. The ranker DID find something; the session had already
|
||||
been told. That is a decline from the reader's side and must be logged as
|
||||
one — the note arms get this for free by passing exclusions into the search,
|
||||
so their zero-result rows already include this case."""
|
||||
log, rec = MagicMock(), MagicMock()
|
||||
hits = [(0.71, fake_rule(id=156, title="A wait with no deadline is a bug"))]
|
||||
await _run_arm(hits, rec, retrieval_log=log, exclude_rule_ids=[156])
|
||||
|
||||
rule_calls = [c for c in log.call_args_list
|
||||
if c.kwargs.get("source") == "write_path_rule"]
|
||||
assert len(rule_calls) == 1
|
||||
assert rule_calls[0].kwargs["results"] == [], (
|
||||
"the row must record what the arm could SHOW, so this row is comparable "
|
||||
"with an auto_inject row, whose exclusions are applied by the search"
|
||||
)
|
||||
rec.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_tool_arm_logs_the_call_that_found_nothing():
|
||||
"""It matters more here than on the sibling. This arm fires on every Bash
|
||||
call, so an empty `sources` row is the normal outcome — and with no row at
|
||||
all, "the ranker declined" is indistinguishable from "the hook never fired",
|
||||
which is exactly the silent failure the arm was built to stop (#3476)."""
|
||||
log, rec = MagicMock(), MagicMock()
|
||||
out = await _run_tool_arm([], rec, retrieval_log=log)
|
||||
|
||||
assert out == {"context": "", "rule_ids": []}
|
||||
assert log.call_count == 1
|
||||
assert log.call_args.kwargs["source"] == "pre_tool_rule"
|
||||
assert log.call_args.kwargs["results"] == []
|
||||
assert log.call_args.kwargs["query"] == "curl -s https://git.example/api/v1/runs", (
|
||||
"the query is the point of the row: it is what a threshold is tuned against"
|
||||
)
|
||||
rec.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_tool_arm_logs_a_call_whose_only_hit_was_already_shown():
|
||||
log, rec = MagicMock(), MagicMock()
|
||||
hits = [(0.71, fake_rule(id=161, title="Reach the forge through its MCP tools"))]
|
||||
out = await _run_tool_arm(hits, rec, retrieval_log=log, exclude_rule_ids=[161])
|
||||
|
||||
assert out["rule_ids"] == []
|
||||
assert log.call_count == 1
|
||||
assert log.call_args.kwargs["results"] == []
|
||||
rec.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_command_the_arm_never_searched_writes_no_row_at_all():
|
||||
"""The one case that must stay silent, and the boundary of the rule above.
|
||||
|
||||
A blank command costs no embedding query, so there was no retrieval to log.
|
||||
A row here would report a call that never happened and drag the clear-rate
|
||||
down with phantom declines — the mirror of the defect, from the other side.
|
||||
"""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
log = MagicMock()
|
||||
with ExitStack() as stack:
|
||||
for ctx in _tool_patches(pc, [], MagicMock(), retrieval_log=log):
|
||||
stack.enter_context(ctx)
|
||||
await pc.build_tool_rule_hint(1, "Bash", " ")
|
||||
|
||||
log.assert_not_called()
|
||||
|
||||
|
||||
def test_neither_rule_arm_logs_its_call_behind_a_results_guard():
|
||||
"""Structural, on top of the behavioural pair above, because the defect was
|
||||
one level of indentation and it appeared INDEPENDENTLY in two places — the
|
||||
pre-tool arm inherited it by being modelled on its sibling. The third arm
|
||||
modelled on either of them is the one this catches.
|
||||
"""
|
||||
pc_src = Path("src/scribe/services/plugin_context.py").read_text()
|
||||
|
||||
# Write-path arm: what remains inside `if fresh:` is the SURFACING log only.
|
||||
guarded = pc_src.split('source="write_path_rule", query=code or path')[1]
|
||||
guarded = guarded.split("if fresh:")[1].split("except Exception:")[0]
|
||||
assert "record_rule_surfaced" in guarded, "the surfacing log must stay guarded"
|
||||
assert "record_retrieval" not in guarded, (
|
||||
"the call log is back inside the results guard — a call that found "
|
||||
"nothing is the only evidence a threshold is set too high"
|
||||
)
|
||||
|
||||
# Pre-tool arm: the call log comes BEFORE the early return.
|
||||
body = pc_src.split("async def build_tool_rule_hint")[1]
|
||||
assert body.index('source="pre_tool_rule"') < body.index("if not fresh:"), (
|
||||
"the pre-tool arm returns before logging its call — a surface with no "
|
||||
"rows at all cannot be told apart from a hook that never fired"
|
||||
)
|
||||
|
||||
|
||||
# ── Suppression: which zeros were the ranker, which were repeats (#3497) ──
|
||||
#
|
||||
# Making the call log unconditional exposed a second ambiguity in the same row.
|
||||
# A zero-result rule call is two unrelated events: the ranker found nothing
|
||||
# above the bar, or it found only what this session already held. Only the
|
||||
# first says anything about the threshold, and a long session excludes its way
|
||||
# into the second — so without the split, the arm looks worse the longer it
|
||||
# runs correctly.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_write_path_arm_reports_what_the_session_already_held():
|
||||
log, rec = MagicMock(), MagicMock()
|
||||
hits = [(0.71, fake_rule(id=156, title="A wait with no deadline is a bug")),
|
||||
(0.70, fake_rule(id=157, title="A loop re-arms in a finally"))]
|
||||
await _run_arm(hits, rec, retrieval_log=log, exclude_rule_ids=[156, 157])
|
||||
|
||||
row = next(c for c in log.call_args_list
|
||||
if c.kwargs.get("source") == "write_path_rule")
|
||||
assert row.kwargs["results"] == []
|
||||
assert row.kwargs["suppressed"] == 2, (
|
||||
"both hits were repeats, so this zero is not evidence about the bar"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_genuine_ranker_decline_reports_zero_suppression():
|
||||
"""Zero, not None. The arm filters in Python, so it always knows — and
|
||||
'measured none' has to stay distinguishable from 'cannot measure'."""
|
||||
log, rec = MagicMock(), MagicMock()
|
||||
await _run_arm([], rec, retrieval_log=log)
|
||||
|
||||
row = next(c for c in log.call_args_list
|
||||
if c.kwargs.get("source") == "write_path_rule")
|
||||
assert row.kwargs["suppressed"] == 0
|
||||
assert row.kwargs["suppressed"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_tool_arm_reports_suppression_too():
|
||||
log, rec = MagicMock(), MagicMock()
|
||||
hits = [(0.75, fake_rule(id=161, title="Reach the forge through its MCP tools"))]
|
||||
await _run_tool_arm(hits, rec, retrieval_log=log, exclude_rule_ids=[161])
|
||||
|
||||
assert log.call_args.kwargs["results"] == []
|
||||
assert log.call_args.kwargs["suppressed"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_shown_hit_is_not_counted_as_suppressed():
|
||||
"""The obvious inverse, worth pinning: `suppressed` counts what was DROPPED,
|
||||
not what came back. Off by one here and every zero row reads as a repeat."""
|
||||
log, rec = MagicMock(), MagicMock()
|
||||
hits = [(0.75, fake_rule(id=161, title="Reach the forge through its MCP tools")),
|
||||
(0.70, fake_rule(id=12, title="Don't run a local stack unless asked"))]
|
||||
out = await _run_tool_arm(hits, rec, retrieval_log=log, exclude_rule_ids=[12])
|
||||
|
||||
assert out["rule_ids"] == [161]
|
||||
assert log.call_args.kwargs["suppressed"] == 1
|
||||
assert len(log.call_args.kwargs["results"]) == 1
|
||||
|
||||
|
||||
# ── The identity that falsified this milestone (#3668) ─────────────────
|
||||
#
|
||||
# `rule_usage.surfaced` == `pre_tool_rule.cleared` + `write_path_rule.cleared`.
|
||||
# Milestone #379 was scoped on a reconstruction that put ~64% of ranked rule
|
||||
# surfacings as never reaching `rule_usage_events`. Five steps were planned
|
||||
# against it. One read of this identity — 17 = 17, then 39 = 39 on a second
|
||||
# window — falsified the whole thing: the gap was two counters that started
|
||||
# recording on different days, not a write path dropping rows.
|
||||
#
|
||||
# So the identity is not a nice-to-have. It is the cheapest true statement
|
||||
# available about this pair of tables, and its absence is what let a magnitude
|
||||
# that merely LOOKED wrong survive a code review and a five-step plan. An
|
||||
# identity that must hold exactly beats a magnitude that looks wrong.
|
||||
#
|
||||
# WHY THE ARM IS THE RIGHT PLACE TO PIN IT, and the readout is not. Inside an
|
||||
# arm, one `fresh` list feeds both recorders in one function, so the counts
|
||||
# cannot legitimately differ — at any limit. The readout-level form is weaker
|
||||
# than it looks: `cleared_threshold` counts CALLS that beat the bar while
|
||||
# `surfaced` counts RULES, and those coincide only while `RULEHINT_LIMIT` is 1.
|
||||
# Raise the limit and the readout identity breaks while nothing is wrong.
|
||||
# `RULEHINT_LIMIT` has already moved once (2 → 1, `2385100`), and that move is
|
||||
# half of why the original reconstruction misread its own numbers.
|
||||
#
|
||||
# Hence three hits below, where production currently returns at most one. The
|
||||
# test is deliberately in a state the limit does not permit today, because what
|
||||
# is being pinned is that the two recorders read the same list — not that the
|
||||
# list happens to be short.
|
||||
|
||||
_THREE_HITS = [
|
||||
(0.81, fake_rule(id=156, title="A wait with no deadline is a bug")),
|
||||
(0.77, fake_rule(id=157, title="A loop re-arms in a finally")),
|
||||
(0.74, fake_rule(id=161, title="Reach the forge through its MCP tools")),
|
||||
]
|
||||
|
||||
_ARMS = [("write_path_rule", _run_arm), ("pre_tool_rule", _run_tool_arm)]
|
||||
|
||||
|
||||
def _both_ends(log, rec, source):
|
||||
"""What the two recorders said about one call, at the same grain.
|
||||
|
||||
Ids rather than counts. Equal counts drawn from different lists is a real
|
||||
way for this to break — an off-by-one slice, or one recorder reading `hits`
|
||||
where the other reads `fresh` in a window where the exclusion happened to
|
||||
remove as many as it added — and a count comparison would call that agreement.
|
||||
"""
|
||||
rows = [c for c in log.call_args_list if c.kwargs.get("source") == source]
|
||||
assert len(rows) == 1, (
|
||||
f"expected exactly one {source} call row, got {len(rows)} — the "
|
||||
f"identity is per call and cannot be read across several"
|
||||
)
|
||||
logged = [rule.id for _score, rule in rows[0].kwargs["results"]]
|
||||
surfaced = [
|
||||
rid
|
||||
for c in rec.call_args_list if c.kwargs.get("source") == source
|
||||
for rid in c.kwargs["rule_ids"]
|
||||
]
|
||||
return logged, surfaced
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("source", "run"), _ARMS, ids=["write_path", "pre_tool"])
|
||||
@pytest.mark.parametrize(
|
||||
("excluded", "expected"),
|
||||
[([], 3), ([157], 2), ([156, 157, 161], 0)],
|
||||
ids=["nothing-held", "one-already-held", "all-already-held"],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_recorders_report_the_same_rules_for_one_call(
|
||||
source, run, excluded, expected
|
||||
):
|
||||
"""One list, two tables, no room to disagree.
|
||||
|
||||
The middle case is the one that discriminates. With nothing excluded both
|
||||
recorders see the same three rules however wrongly they are wired, so an
|
||||
arm logging `hits` to the call log and `fresh` to the surfacing log passes
|
||||
that case and fails this one — and logging `hits` is exactly the divergence
|
||||
that would manufacture an apparent write loss out of a correct system.
|
||||
"""
|
||||
log, rec = MagicMock(), MagicMock()
|
||||
await run(list(_THREE_HITS), rec, retrieval_log=log, exclude_rule_ids=excluded)
|
||||
|
||||
logged, surfaced = _both_ends(log, rec, source)
|
||||
assert surfaced == logged, (
|
||||
f"{source} told its two tables different stories about one call: the "
|
||||
f"call log recorded {logged} and the surfacing log recorded {surfaced}. "
|
||||
f"Both come from `fresh`, in one function, so any difference is a bug "
|
||||
f"in the wiring — and it is the shape that reads as a lost write when "
|
||||
f"the two tables are later compared in aggregate (#3668)."
|
||||
)
|
||||
assert len(logged) == expected, (
|
||||
"the fixture stopped exercising what it claims to; check the exclusion "
|
||||
"filter still runs before both recorders"
|
||||
)
|
||||
@@ -23,7 +23,7 @@ def test_backup_version_is_current():
|
||||
|
||||
(Named for the number it asserted until v10, which is exactly the drift a
|
||||
name-carrying-a-value invites; it now says what it checks.)"""
|
||||
assert backup.BACKUP_VERSION == 13
|
||||
assert backup.BACKUP_VERSION == 14
|
||||
|
||||
|
||||
def _exportable_note(**over):
|
||||
@@ -133,6 +133,7 @@ def _column_guard_targets():
|
||||
from scribe.models.note_draft import NoteDraft
|
||||
from scribe.models.note_supersession import NoteSupersession
|
||||
from scribe.models.note_usage import NoteUsageEvent
|
||||
from scribe.models.rule_usage import RuleUsageEvent
|
||||
from scribe.models.note_version import NoteVersion
|
||||
from scribe.models.rule_version import RuleVersion
|
||||
from scribe.models.project import Project
|
||||
@@ -162,6 +163,7 @@ def _column_guard_targets():
|
||||
"note_supersessions": (NoteSupersession, backup._note_supersession_rows),
|
||||
"rule_relations": (RuleRelation, backup._rule_relation_rows),
|
||||
"note_usage_events": (NoteUsageEvent, backup._usage_event_rows),
|
||||
"rule_usage_events": (RuleUsageEvent, backup._rule_usage_event_rows),
|
||||
"design_systems": (DesignSystem, backup._design_system_rows),
|
||||
"design_tokens": (DesignToken, backup._design_token_rows),
|
||||
"repo_bindings": (RepoBinding, backup._repo_binding_rows),
|
||||
|
||||
@@ -419,7 +419,8 @@ async def test_write_path_semantic_arm_asks_for_experience_not_just_snippets():
|
||||
rec = MagicMock()
|
||||
with patch.object(pc, "get_writepath_config",
|
||||
AsyncMock(return_value={"enabled": True, "threshold": 0.6,
|
||||
"top_k": 3})), \
|
||||
"top_k": 3,
|
||||
"rule_threshold": 0.72})), \
|
||||
patch.object(pc.snippets_svc, "list_snippets",
|
||||
AsyncMock(return_value=([], 0))), \
|
||||
patch.object(pc, "semantic_search_notes", search), \
|
||||
@@ -451,7 +452,8 @@ async def test_write_path_labels_a_non_snippet_hit_with_its_kind():
|
||||
(0.71, fake_note(id=7, title="Debounce dropped the trailing call", user_id=1, is_task=True, task_kind="issue"))]
|
||||
with patch.object(pc, "get_writepath_config",
|
||||
AsyncMock(return_value={"enabled": True, "threshold": 0.6,
|
||||
"top_k": 3})), \
|
||||
"top_k": 3,
|
||||
"rule_threshold": 0.72})), \
|
||||
patch.object(pc.snippets_svc, "list_snippets",
|
||||
AsyncMock(return_value=([], 0))), \
|
||||
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=hits)), \
|
||||
|
||||
@@ -57,6 +57,68 @@ def test_build_payload_rounds_scores_to_5dp():
|
||||
assert p["result_ids"][0]["score"] == 0.12346
|
||||
|
||||
|
||||
# ─── suppression: "not measured" is not "none" (#3497) ───────────────────────
|
||||
|
||||
|
||||
def test_a_caller_that_cannot_measure_suppression_stores_null():
|
||||
"""The distinction the whole column exists for.
|
||||
|
||||
A surface that passes its exclusions into the search never sees what was
|
||||
dropped. Storing 0 would assert a clean run nobody observed — reading an
|
||||
artifact as a measurement, which is exactly #3311's mistake.
|
||||
"""
|
||||
p = _build_payload(
|
||||
user_id=1, source="auto_inject", query="q", threshold=0.6,
|
||||
limit=3, project_id=None, is_task=None, results=[], duration_ms=None,
|
||||
)
|
||||
assert p["suppressed_count"] is None, "unmeasured must not render as zero"
|
||||
|
||||
|
||||
def test_a_caller_that_measured_no_suppression_stores_zero():
|
||||
"""The other side of it. Zero is a real observation and must survive."""
|
||||
p = _build_payload(
|
||||
user_id=1, source="pre_tool_rule", query="git status", threshold=0.6,
|
||||
limit=1, project_id=None, is_task=None, results=[], duration_ms=None,
|
||||
suppressed=0,
|
||||
)
|
||||
assert p["suppressed_count"] == 0
|
||||
|
||||
|
||||
def test_the_count_of_hits_the_reader_already_held_is_carried():
|
||||
p = _build_payload(
|
||||
user_id=1, source="write_path_rule", query="code", threshold=0.6,
|
||||
limit=2, project_id=None, is_task=None, results=[], duration_ms=None,
|
||||
suppressed=2,
|
||||
)
|
||||
assert p["result_count"] == 0
|
||||
assert p["suppressed_count"] == 2, (
|
||||
"a zero row that was really two repeats must be distinguishable from "
|
||||
"a zero row where the ranker found nothing"
|
||||
)
|
||||
|
||||
|
||||
def test_the_readout_reports_unmeasured_suppression_as_none():
|
||||
"""`_bucket` renders the aggregate. No row reporting it → null, never a
|
||||
zeroed dict: a zeroed dict states a measurement nobody made."""
|
||||
from scribe.services.retrieval_telemetry import _bucket
|
||||
|
||||
# calls, zero, p10, p50, p90, min, max, avg_n, dur,
|
||||
# measured, supp_calls, supp_zero, miss_calls, miss_p50, miss_p90, miss_max
|
||||
unmeasured = _bucket([326, 114, 0.6, 0.68, 0.77, 0.55, 0.85, 1.7, 130.9,
|
||||
0, 0, 0, 0, None, None, None])
|
||||
assert unmeasured["suppression"] is None
|
||||
|
||||
measured = _bucket([35, 34, 0.75, 0.75, 0.75, 0.75, 0.75, 0.03, 51.9,
|
||||
35, 9, 9, 0, None, None, None])
|
||||
assert measured["suppression"] == {
|
||||
"measured_calls": 35,
|
||||
"calls_with_suppression": 9,
|
||||
"zero_because_already_shown": 9,
|
||||
}
|
||||
# The number the threshold is actually tuned from.
|
||||
assert measured["zero_result_calls"] - 9 == 25
|
||||
|
||||
|
||||
def test_record_retrieval_without_event_loop_is_safe():
|
||||
"""Called from a sync context (no running loop) it must swallow and return,
|
||||
never raise — telemetry can't be allowed to break a caller."""
|
||||
@@ -162,7 +224,10 @@ async def test_retrieval_summary_reads_what_the_writer_wrote(_dispose_engine):
|
||||
ai = out["sources"]["auto_inject"]
|
||||
assert ai["calls"] == 4
|
||||
assert ai["zero_result_calls"] == 1
|
||||
assert ai["cleared_threshold"] == 2 # 0.91 and 0.72, not 0.40
|
||||
assert "cleared_threshold" not in ai, (
|
||||
"the tautology is back: it was true exactly when result_count > 0, "
|
||||
"so it reported nothing zero_result_calls did not (#3670)"
|
||||
)
|
||||
# p50 over the three scored calls; the empty one contributes no score.
|
||||
assert ai["top_score"]["p50"] == pytest.approx(0.72, abs=1e-4)
|
||||
assert ai["top_score"]["min"] == pytest.approx(0.40, abs=1e-4)
|
||||
@@ -195,6 +260,10 @@ async def test_retrieval_summary_is_empty_not_broken_for_a_fresh_install(_dispos
|
||||
assert out["sources"] == {}
|
||||
assert out["usage"]["pull_through"] is None # no division by zero
|
||||
assert out["usage"]["surfaced"] == 0
|
||||
# An empty dict, not a missing key and not a failure flag — the same
|
||||
# "no rows" / "read broke" distinction the rest of this readout keeps.
|
||||
assert out["usage"]["by_source"] == {}
|
||||
assert "by_source_failed" not in out["usage"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -222,3 +291,752 @@ async def test_retrieval_summary_sees_only_its_own_users_telemetry(_dispose_engi
|
||||
async with async_session() as s:
|
||||
await s.execute(delete(RetrievalLog).where(RetrievalLog.user_id == 990004))
|
||||
await s.commit()
|
||||
|
||||
|
||||
# ─── per-source pull-through (#3311) ─────────────────────────────────────────
|
||||
# Integration for the same reason the block above is: this is a self-join with
|
||||
# two DISTINCT subqueries and a LIKE escape, which is a new SQL shape in a
|
||||
# module whose one production outage (#2663) was a new SQL shape the database
|
||||
# rejected inside a broad except. A mock would pass on a query Postgres refuses.
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_by_source_separates_a_surface_that_earns_its_noise_from_one_that_does_not(
|
||||
_dispose_engine,
|
||||
):
|
||||
"""The whole point: the corpus average cannot say WHICH surface is working.
|
||||
|
||||
Two ranked surfaces, identical volume, opposite outcomes — and a top-level
|
||||
ratio that describes neither of them.
|
||||
"""
|
||||
from sqlalchemy import delete
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note_usage import NoteUsageEvent
|
||||
from scribe.services.retrieval_telemetry import retrieval_summary
|
||||
|
||||
UID = 990010
|
||||
async with async_session() as s:
|
||||
s.add_all([
|
||||
# auto_inject chose note 1 three times and note 2 once. Three
|
||||
# surfacings of one note is ONE note surfaced — the DISTINCT that
|
||||
# keeps the join from multiplying rows is what this pins.
|
||||
NoteUsageEvent(user_id=UID, note_id=1, event="surfaced", source="auto_inject"),
|
||||
NoteUsageEvent(user_id=UID, note_id=1, event="surfaced", source="auto_inject"),
|
||||
NoteUsageEvent(user_id=UID, note_id=1, event="surfaced", source="auto_inject"),
|
||||
NoteUsageEvent(user_id=UID, note_id=2, event="surfaced", source="auto_inject"),
|
||||
# write_path_semantic chose two notes and got nothing opened.
|
||||
NoteUsageEvent(user_id=UID, note_id=3, event="surfaced", source="write_path_semantic"),
|
||||
NoteUsageEvent(user_id=UID, note_id=4, event="surfaced", source="write_path_semantic"),
|
||||
# One agent pull, of a note only auto_inject surfaced.
|
||||
NoteUsageEvent(user_id=UID, note_id=1, event="pulled", source="mcp_get_note"),
|
||||
])
|
||||
await s.commit()
|
||||
|
||||
try:
|
||||
out = await retrieval_summary(UID, days=30)
|
||||
assert out["read_failed"] is False
|
||||
by_source = out["usage"]["by_source"]
|
||||
assert "by_source_failed" not in out["usage"], "the join did not execute"
|
||||
|
||||
ai = by_source["auto_inject"]
|
||||
assert ai["notes_surfaced"] == 2, "three surfacings of note 1 are one note"
|
||||
assert ai["notes_pulled"] == 1
|
||||
assert ai["pull_through"] == pytest.approx(0.5)
|
||||
|
||||
wp = by_source["write_path_semantic"]
|
||||
assert wp["notes_surfaced"] == 2
|
||||
assert wp["notes_pulled"] == 0
|
||||
# 0.0, NOT None. "This surface produced nothing" is a finding; None is
|
||||
# what a surface with no data reads as, and they must not look alike.
|
||||
assert wp["pull_through"] == 0.0
|
||||
|
||||
# And the number that exists today, which is true of neither surface:
|
||||
# one agent pull over six ranked surfacings.
|
||||
assert out["usage"]["pull_through"] == pytest.approx(1 / 6, abs=1e-4)
|
||||
finally:
|
||||
async with async_session() as s:
|
||||
await s.execute(delete(NoteUsageEvent).where(NoteUsageEvent.user_id == UID))
|
||||
await s.commit()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_ambient_surface_reports_its_counts_but_no_ratio(_dispose_engine):
|
||||
"""`enter_project` bulk-loads records; nothing CHOSE them. "Surfaced often,
|
||||
opened never" is not a judgment about a record that was never picked, so the
|
||||
counts stay visible and the ratio that would be misread is null."""
|
||||
from sqlalchemy import delete
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note_usage import NoteUsageEvent
|
||||
from scribe.services.retrieval_telemetry import retrieval_summary
|
||||
|
||||
UID = 990011
|
||||
async with async_session() as s:
|
||||
s.add_all([
|
||||
NoteUsageEvent(user_id=UID, note_id=1, event="surfaced", source="enter_project"),
|
||||
NoteUsageEvent(user_id=UID, note_id=2, event="surfaced", source="enter_project"),
|
||||
])
|
||||
await s.commit()
|
||||
|
||||
try:
|
||||
row = (await retrieval_summary(UID, days=30))["usage"]["by_source"]["enter_project"]
|
||||
assert row["ambient"] is True
|
||||
assert row["notes_surfaced"] == 2
|
||||
assert row["pull_through"] is None
|
||||
finally:
|
||||
async with async_session() as s:
|
||||
await s.execute(delete(NoteUsageEvent).where(NoteUsageEvent.user_id == UID))
|
||||
await s.commit()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_agent_pull_filter_does_not_treat_its_underscore_as_a_wildcard(
|
||||
_dispose_engine,
|
||||
):
|
||||
"""`_` is a LIKE wildcard, so an unescaped `LIKE 'mcp_%'` also matches
|
||||
`mcpXsomething`. The Python half of this readout uses str.startswith and
|
||||
cannot have the bug; the SQL half needs autoescape to match it, and nothing
|
||||
else in the payload would reveal the difference."""
|
||||
from sqlalchemy import delete
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note_usage import NoteUsageEvent
|
||||
from scribe.services.retrieval_telemetry import retrieval_summary
|
||||
|
||||
UID = 990012
|
||||
async with async_session() as s:
|
||||
s.add_all([
|
||||
NoteUsageEvent(user_id=UID, note_id=1, event="surfaced", source="auto_inject"),
|
||||
# Not an agent pull: the door is `mcpXget_note`, not `mcp_get_note`.
|
||||
NoteUsageEvent(user_id=UID, note_id=1, event="pulled", source="mcpXget_note"),
|
||||
])
|
||||
await s.commit()
|
||||
|
||||
try:
|
||||
row = (await retrieval_summary(UID, days=30))["usage"]["by_source"]["auto_inject"]
|
||||
assert row["notes_pulled"] == 0, "a wildcard match counted a non-agent pull"
|
||||
assert row["pull_through"] == 0.0
|
||||
finally:
|
||||
async with async_session() as s:
|
||||
await s.execute(delete(NoteUsageEvent).where(NoteUsageEvent.user_id == UID))
|
||||
await s.commit()
|
||||
|
||||
|
||||
# ─── rule usage (milestone 333 step 3) ───────────────────────────────────────
|
||||
# Integration, for the same reason the block above is: these are real GROUP BYs
|
||||
# and count(distinct) against a table that did not exist a commit ago, in a
|
||||
# module whose one production outage (#2663) was a SQL shape the database
|
||||
# rejected inside a broad except. A mock would agree with whatever the code
|
||||
# does, including nothing.
|
||||
|
||||
|
||||
async def _rule_events(uid, rows):
|
||||
"""Write (event, source) pairs for one rule and hand back a cleanup."""
|
||||
from sqlalchemy import delete
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.rule_usage import RuleUsageEvent
|
||||
|
||||
async with async_session() as s:
|
||||
s.add_all([
|
||||
RuleUsageEvent(user_id=uid, rule_id=rid, event=ev, source=src)
|
||||
for rid, ev, src in rows
|
||||
])
|
||||
await s.commit()
|
||||
|
||||
async def cleanup():
|
||||
async with async_session() as s:
|
||||
await s.execute(
|
||||
delete(RuleUsageEvent).where(RuleUsageEvent.user_id == uid)
|
||||
)
|
||||
await s.commit()
|
||||
|
||||
return cleanup
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_rule_usage_is_a_coherent_zero_on_a_fresh_install(_dispose_engine):
|
||||
"""Every rule in an existing install predates this table, so "no events" is
|
||||
the normal state for a while. It must read as zero, not as a missing key
|
||||
and not as a failure — the same "no rows" / "read broke" distinction the
|
||||
rest of this readout keeps (#2663).
|
||||
|
||||
`pull_through` is None rather than 0.0, matching the note block: a ratio of
|
||||
zero asserts "rules were shown and none opened", which with an empty
|
||||
numerator AND denominator is a claim the data does not support.
|
||||
"""
|
||||
from scribe.services.retrieval_telemetry import retrieval_summary
|
||||
|
||||
out = await retrieval_summary(990010, days=30)
|
||||
assert out["read_failed"] is False
|
||||
assert "rule_usage_failed" not in out["rule_usage"]
|
||||
assert out["rule_usage"]["surfaced"] == 0
|
||||
assert out["rule_usage"]["pulled"] == 0
|
||||
assert out["rule_usage"]["distinct_rules_surfaced"] == 0
|
||||
assert out["rule_usage"]["pull_through"] is None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_agent_reading_a_surfaced_rule_is_what_moves_the_ratio(_dispose_engine):
|
||||
"""The whole point of the milestone: the arm can now be told apart from a
|
||||
bar it cannot fail to clear."""
|
||||
from scribe.services.retrieval_telemetry import retrieval_summary
|
||||
|
||||
cleanup = await _rule_events(990011, [
|
||||
(5001, "surfaced", "write_path_rule"),
|
||||
(5002, "surfaced", "write_path_rule"),
|
||||
(5001, "pulled", "mcp_get_rule"),
|
||||
])
|
||||
try:
|
||||
ru = (await retrieval_summary(990011, days=30))["rule_usage"]
|
||||
assert ru["surfaced"] == 2
|
||||
assert ru["pulled"] == 1
|
||||
assert ru["pulled_by_agent"] == 1
|
||||
assert ru["pulled_by_human"] == 0
|
||||
assert ru["distinct_rules_surfaced"] == 2
|
||||
assert ru["distinct_rules_pulled"] == 1
|
||||
assert ru["pull_through"] == 0.5
|
||||
finally:
|
||||
await cleanup()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_person_browsing_the_rule_list_does_not_move_the_ratio(_dispose_engine):
|
||||
"""The mcp_/rest_ split, and it carries more weight here than for notes.
|
||||
|
||||
The arm's claim is "this rule may apply to what you are writing". Only an
|
||||
agent opening it says that claim landed; a person clicking through the rule
|
||||
list in the web UI says nothing about the hint. Both are still counted in
|
||||
`pulled`, so "is this rule dead weight?" stays answerable.
|
||||
"""
|
||||
from scribe.services.retrieval_telemetry import retrieval_summary
|
||||
|
||||
cleanup = await _rule_events(990012, [
|
||||
(5003, "surfaced", "write_path_rule"),
|
||||
(5003, "pulled", "rest_rule"),
|
||||
])
|
||||
try:
|
||||
ru = (await retrieval_summary(990012, days=30))["rule_usage"]
|
||||
assert ru["pulled"] == 1
|
||||
assert ru["pulled_by_human"] == 1
|
||||
assert ru["pulled_by_agent"] == 0
|
||||
# Surfaced once, opened by nobody who matters to this question.
|
||||
assert ru["pull_through"] == 0.0
|
||||
finally:
|
||||
await cleanup()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_rule_events_stay_out_of_the_note_block(_dispose_engine):
|
||||
"""The separation, asserted rather than assumed.
|
||||
|
||||
`usage` is what existing callers already read and compare across windows.
|
||||
If rule events leaked into it, that number would move for a reason nobody
|
||||
was told about — and the rule arm would still be invisible, because a few
|
||||
dozen rules against thousands of notes is noise on the note ratio.
|
||||
"""
|
||||
from scribe.services.retrieval_telemetry import retrieval_summary
|
||||
|
||||
cleanup = await _rule_events(990013, [
|
||||
(5004, "surfaced", "write_path_rule"),
|
||||
(5004, "pulled", "mcp_get_rule"),
|
||||
])
|
||||
try:
|
||||
out = await retrieval_summary(990013, days=30)
|
||||
assert out["rule_usage"]["surfaced"] == 1
|
||||
# The note block saw none of it.
|
||||
assert out["usage"]["surfaced"] == 0
|
||||
assert out["usage"]["pulled"] == 0
|
||||
assert out["usage"]["pull_through"] is None
|
||||
finally:
|
||||
await cleanup()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_rule_usage_sees_only_its_own_users_events(_dispose_engine):
|
||||
"""Same access rule as the rest of the readout — the owner filter IS the
|
||||
rule for telemetry, which is not a shared record kind."""
|
||||
from scribe.services.retrieval_telemetry import retrieval_summary
|
||||
|
||||
cleanup = await _rule_events(990014, [
|
||||
(5005, "surfaced", "write_path_rule"),
|
||||
(5005, "pulled", "mcp_get_rule"),
|
||||
])
|
||||
try:
|
||||
assert (await retrieval_summary(990015, days=30))["rule_usage"]["surfaced"] == 0
|
||||
assert (await retrieval_summary(990014, days=30))["rule_usage"]["surfaced"] == 1
|
||||
finally:
|
||||
await cleanup()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_preload_lands_in_ambient_and_never_in_the_ratio(_dispose_engine):
|
||||
"""The split that makes the always-on set judgeable (#3473).
|
||||
|
||||
Pull-through asks "was that hint any use", and only a surface that CHOSE
|
||||
what it showed can be judged by it. If the preload counted toward the
|
||||
denominator, growing the always-on set would DEPRESS the arm's measured
|
||||
precision and trimming it would flatter it — neither for any reason to do
|
||||
with the arm. So the resident deliveries are counted, reported, and kept
|
||||
out of the ratio.
|
||||
"""
|
||||
from scribe.services.retrieval_telemetry import retrieval_summary
|
||||
|
||||
cleanup = await _rule_events(990012, [
|
||||
# One rule the arm actually chose, and opened.
|
||||
(5101, "surfaced", "write_path_rule"),
|
||||
(5101, "pulled", "mcp_get_rule"),
|
||||
# Four bulk deliveries across every shape of preload. Nobody chose any
|
||||
# of them, and none may touch the denominator.
|
||||
(5102, "surfaced", "session_start"),
|
||||
(5103, "surfaced", "list_always_on_rules"),
|
||||
(5104, "surfaced", "enter_project"),
|
||||
(5105, "surfaced", "get_milestone"),
|
||||
])
|
||||
try:
|
||||
ru = (await retrieval_summary(990012, days=30))["rule_usage"]
|
||||
|
||||
assert ru["surfaced"] == 1, "only the arm chose a rule"
|
||||
assert ru["ambient"] == 4, "the four bulk deliveries are reported, not dropped"
|
||||
|
||||
# 1 agent pull over 1 RANKED surfacing. Were the ambient four folded in
|
||||
# the ratio would read 0.2 — the arm looking four times worse for
|
||||
# having a large resident set beside it.
|
||||
assert ru["pull_through"] == 1.0
|
||||
|
||||
# Dead-weight detection needs both classes: a rule delivered by the
|
||||
# preload and never opened is the case that reading matters most for.
|
||||
assert ru["distinct_rules_surfaced"] == 5
|
||||
assert ru["distinct_rules_pulled"] == 1
|
||||
finally:
|
||||
await cleanup()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_ambient_alone_reports_no_ratio(_dispose_engine):
|
||||
"""A brand-new install loads rules every session and may never trigger the
|
||||
arm. That must read as "no ranked surfacings yet", not as a precision of
|
||||
zero — the reading that would make a working install look broken."""
|
||||
from scribe.services.retrieval_telemetry import retrieval_summary
|
||||
|
||||
cleanup = await _rule_events(990013, [
|
||||
(5201, "surfaced", "session_start"),
|
||||
(5202, "surfaced", "session_start"),
|
||||
])
|
||||
try:
|
||||
ru = (await retrieval_summary(990013, days=30))["rule_usage"]
|
||||
assert ru["ambient"] == 2
|
||||
assert ru["surfaced"] == 0
|
||||
assert ru["pull_through"] is None
|
||||
finally:
|
||||
await cleanup()
|
||||
|
||||
|
||||
# ── Window coverage (#3712) ────────────────────────────────────────────
|
||||
#
|
||||
# A counter added last week, read over a 30-day window, reports a real count
|
||||
# against an imagined denominator. The result is a plausible FRACTION rather
|
||||
# than an obvious zero, which is what makes it dangerous — #379 spent five
|
||||
# planned steps on a defect that turned out to be a window opening before the
|
||||
# recording it was measuring existed.
|
||||
|
||||
|
||||
def test_coverage_says_nothing_rather_than_false_when_nothing_was_recorded():
|
||||
"""Null, never False. "No measurement" is not "partial measurement".
|
||||
|
||||
The same distinction `suppression`'s null carries (#3497): absent must not
|
||||
read as a verdict. A False here would assert the window is under-covered,
|
||||
which is a claim nobody is in a position to make.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from scribe.services.retrieval_telemetry import _coverage
|
||||
|
||||
since = datetime(2026, 9, 1, tzinfo=timezone.utc)
|
||||
assert _coverage(None, since) == {
|
||||
"complete_from": None, "covers_window": None,
|
||||
}
|
||||
|
||||
|
||||
def test_coverage_reads_a_start_before_the_window_as_covered():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from scribe.services.retrieval_telemetry import _coverage
|
||||
|
||||
since = datetime(2026, 9, 1, tzinfo=timezone.utc)
|
||||
older = datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||
newer = datetime(2026, 9, 5, tzinfo=timezone.utc)
|
||||
|
||||
assert _coverage(older, since)["covers_window"] is True
|
||||
assert _coverage(newer, since)["covers_window"] is False, (
|
||||
"a counter that started inside the window covers only part of it"
|
||||
)
|
||||
assert _coverage(newer, since)["complete_from"] == newer.isoformat()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_coverage_is_per_source_because_the_table_is_older_than_its_arms(
|
||||
_dispose_engine,
|
||||
):
|
||||
"""THE grain question, and the reason a per-table answer is useless.
|
||||
|
||||
`retrieval_logs` accumulates for months. A table-level "earliest row"
|
||||
therefore says months for every source it holds — including one added
|
||||
days ago whose counter means something quite different. The old source
|
||||
would vouch for the young one, which is exactly the reading this exists
|
||||
to prevent.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.retrieval_log import RetrievalLog
|
||||
from scribe.services.retrieval_telemetry import retrieval_summary
|
||||
|
||||
UID = 990077
|
||||
now = datetime.now(timezone.utc)
|
||||
async with async_session() as s:
|
||||
# An old surface, recording since well before any window we ask for,
|
||||
# AND still recording inside it. Both rows are needed: `complete_from`
|
||||
# comes from the all-time query, but a source only gets a bucket at all
|
||||
# if it has rows in the window, so the 90-day row alone would leave
|
||||
# nothing to assert on.
|
||||
s.add(RetrievalLog(
|
||||
user_id=UID, source="auto_inject", result_count=1,
|
||||
created_at=now - timedelta(days=90),
|
||||
))
|
||||
s.add(RetrievalLog(
|
||||
user_id=UID, source="auto_inject", result_count=1,
|
||||
created_at=now - timedelta(days=1),
|
||||
))
|
||||
# A young arm, first written INSIDE the window below.
|
||||
s.add(RetrievalLog(
|
||||
user_id=UID, source="pre_tool_rule", result_count=1,
|
||||
created_at=now - timedelta(days=2),
|
||||
))
|
||||
await s.commit()
|
||||
|
||||
try:
|
||||
out = await retrieval_summary(UID, days=30)
|
||||
|
||||
assert out["sources"]["auto_inject"]["covers_window"] is True
|
||||
assert out["sources"]["pre_tool_rule"]["covers_window"] is False, (
|
||||
"the young arm was reported as covering a 30-day window — the "
|
||||
"table's age has been allowed to vouch for one of its sources"
|
||||
)
|
||||
finally:
|
||||
async with async_session() as s:
|
||||
await s.execute(delete(RetrievalLog).where(RetrievalLog.user_id == UID))
|
||||
await s.commit()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_surface_that_went_silent_is_not_the_same_as_one_that_never_ran(
|
||||
_dispose_engine,
|
||||
):
|
||||
"""#3720 — absent is how "never existed" renders, so it cannot also be how
|
||||
"stopped recording" renders.
|
||||
|
||||
A surface losing its recorder is one of the failures this milestone exists
|
||||
to make visible, and dropping it from the readout is the most complete way
|
||||
to hide it. Zero here is a real measurement: the table proves the source
|
||||
was recording, and it made no calls across a window it fully covers.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.retrieval_log import RetrievalLog
|
||||
from scribe.services.retrieval_telemetry import retrieval_summary
|
||||
|
||||
UID = 990078
|
||||
now = datetime.now(timezone.utc)
|
||||
async with async_session() as s:
|
||||
# Recorded once, well before the window, and never since.
|
||||
s.add(RetrievalLog(
|
||||
user_id=UID, source="auto_inject", result_count=3, top_score=0.81,
|
||||
created_at=now - timedelta(days=60),
|
||||
))
|
||||
await s.commit()
|
||||
|
||||
try:
|
||||
out = await retrieval_summary(UID, days=7)
|
||||
|
||||
assert "auto_inject" in out["sources"], (
|
||||
"a source with rows in the table but none in the window was "
|
||||
"dropped from the readout — a surface that stopped recording now "
|
||||
"reads exactly like one that never existed"
|
||||
)
|
||||
quiet = out["sources"]["auto_inject"]
|
||||
assert quiet["calls"] == 0
|
||||
# The window IS covered; what was observed across it is nothing.
|
||||
assert quiet["covers_window"] is True
|
||||
# ...but nothing was sampled, so no distribution may be claimed. A
|
||||
# zeroed score would assert a measurement, which is #3311's mistake.
|
||||
assert quiet["top_score"] == {
|
||||
"p10": None, "p50": None, "p90": None, "min": None, "max": None,
|
||||
}
|
||||
assert quiet["suppression"] is None
|
||||
assert quiet["avg_result_count"] is None
|
||||
finally:
|
||||
async with async_session() as s:
|
||||
await s.execute(delete(RetrievalLog).where(RetrievalLog.user_id == UID))
|
||||
await s.commit()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_section_is_complete_only_from_its_latest_contributor(
|
||||
_dispose_engine,
|
||||
):
|
||||
"""A sum is complete once EVERY contributor was being written — so the
|
||||
section takes the LATEST first-row, not the earliest.
|
||||
|
||||
Taking the earliest would be worse than reporting nothing: it would pick
|
||||
the oldest source in the table and use it to certify a total that a
|
||||
newer source is still only partly contributing to. That is the original
|
||||
error in miniature.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.rule_usage import RuleUsageEvent
|
||||
from scribe.services.retrieval_telemetry import retrieval_summary
|
||||
|
||||
UID = 990078
|
||||
now = datetime.now(timezone.utc)
|
||||
old = now - timedelta(days=90)
|
||||
young = now - timedelta(days=2)
|
||||
async with async_session() as s:
|
||||
s.add_all([
|
||||
RuleUsageEvent(
|
||||
user_id=UID, rule_id=1, event="surfaced",
|
||||
source="list_always_on_rules", created_at=old,
|
||||
),
|
||||
RuleUsageEvent(
|
||||
user_id=UID, rule_id=2, event="surfaced",
|
||||
source="pre_tool_rule", created_at=young,
|
||||
),
|
||||
])
|
||||
await s.commit()
|
||||
|
||||
try:
|
||||
out = await retrieval_summary(UID, days=30)
|
||||
ru = out["rule_usage"]
|
||||
|
||||
assert ru["complete_from"] == young.isoformat(), (
|
||||
"the section reported completeness from its OLDEST source; a "
|
||||
"total is only as complete as its newest contributor"
|
||||
)
|
||||
assert ru["covers_window"] is False
|
||||
finally:
|
||||
async with async_session() as s:
|
||||
await s.execute(delete(RuleUsageEvent).where(RuleUsageEvent.user_id == UID))
|
||||
await s.commit()
|
||||
|
||||
|
||||
# ─── the bar can only be judged from what it rejected (#3670) ────────────────
|
||||
#
|
||||
# `cleared_threshold` was the number the docstring told a reader to look at
|
||||
# first. It was `calls - zero_result_calls` under another name: the search
|
||||
# applies the bar before returning, so every returned result cleared it by
|
||||
# construction and a call with nothing has no score to compare.
|
||||
# `zero_result_calls + cleared_threshold == calls` held on all nineteen
|
||||
# source/window readings ever taken — no near-misses, no exceptions.
|
||||
#
|
||||
# What replaced it cannot go the same way, and the reason is structural rather
|
||||
# than careful naming: `near_misses` is measured on the calls the bar TURNED
|
||||
# AWAY, using a score the bar never saw. No arrangement of `calls`,
|
||||
# `zero_result_calls` and `result_count` derives it.
|
||||
|
||||
|
||||
def test_a_call_that_returned_nothing_still_records_what_it_nearly_showed():
|
||||
"""The whole point, at the payload grain.
|
||||
|
||||
This is the row a threshold is tuned from and the one that used to carry no
|
||||
score at all: `top_score` and `min_score` are both null here, correctly, and
|
||||
a reader was left unable to tell a bar rejecting 0.71s from one rejecting
|
||||
0.30s. Both render as a zero-result call.
|
||||
"""
|
||||
p = _build_payload(
|
||||
user_id=1, source="pre_tool_rule", query="git push --force",
|
||||
threshold=0.72, limit=1, project_id=None, is_task=None,
|
||||
results=[], duration_ms=None, best_available=0.7104,
|
||||
)
|
||||
assert p["result_count"] == 0
|
||||
assert p["top_score"] is None, "nothing was shown, so nothing has a top score"
|
||||
assert p["best_available_score"] == 0.7104, (
|
||||
"the losing score was discarded — the only figure that survives a call "
|
||||
"returning nothing, and the only one a bar can be judged from"
|
||||
)
|
||||
|
||||
|
||||
def test_a_caller_that_did_not_measure_the_near_miss_stores_null():
|
||||
"""Null, never 0.0. A zero here reads as "the corpus held nothing remotely
|
||||
relevant" — a claim about the corpus invented out of a caller's silence,
|
||||
which is #3311's substitution in a new field."""
|
||||
p = _build_payload(
|
||||
user_id=1, source="auto_inject", query="q", threshold=0.6,
|
||||
limit=3, project_id=None, is_task=None, results=[], duration_ms=None,
|
||||
)
|
||||
assert p["best_available_score"] is None
|
||||
|
||||
|
||||
def test_the_readout_reports_unmeasured_near_misses_as_none():
|
||||
"""`_bucket`'s half of the same discipline, and the reason it is a block
|
||||
rather than three loose keys: old rows predate the column, so a window can
|
||||
legitimately contain declines nobody measured."""
|
||||
from scribe.services.retrieval_telemetry import _bucket
|
||||
|
||||
# calls, zero, p10, p50, p90, min, max, avg_n, dur,
|
||||
# measured, supp_calls, supp_zero, miss_calls, miss_p50, miss_p90, miss_max
|
||||
none_measured = _bucket([326, 114, 0.6, 0.68, 0.77, 0.55, 0.85, 1.7, 130.9,
|
||||
0, 0, 0, 0, None, None, None])
|
||||
assert none_measured["near_misses"] is None
|
||||
|
||||
measured = _bucket([326, 114, 0.6, 0.68, 0.77, 0.55, 0.85, 1.7, 130.9,
|
||||
0, 0, 0, 114, 0.61, 0.7104, 0.7189])
|
||||
assert measured["near_misses"] == {
|
||||
"measured_calls": 114,
|
||||
"p50": 0.61,
|
||||
"p90": 0.7104,
|
||||
"max": 0.7189,
|
||||
}
|
||||
|
||||
|
||||
def test_the_readout_carries_no_field_derivable_from_its_neighbours():
|
||||
"""The guard that would have caught #3670 on the day it shipped.
|
||||
|
||||
`cleared_threshold` survived because it had its own name and its own
|
||||
docstring paragraph, and nobody added the two numbers beside it. This
|
||||
asserts the identity that held on every reading ever taken — and if a
|
||||
future field reintroduces it under a new name, the sum below is where it
|
||||
shows up.
|
||||
"""
|
||||
from scribe.services.retrieval_telemetry import _bucket
|
||||
|
||||
b = _bucket([326, 114, 0.6, 0.68, 0.77, 0.55, 0.85, 1.7, 130.9,
|
||||
0, 0, 0, 114, 0.61, 0.71, 0.72])
|
||||
derivable = {
|
||||
k for k, v in b.items()
|
||||
if isinstance(v, int) and not isinstance(v, bool)
|
||||
and k not in ("calls", "zero_result_calls")
|
||||
and v == b["calls"] - b["zero_result_calls"]
|
||||
}
|
||||
assert not derivable, (
|
||||
f"{sorted(derivable)} equals calls - zero_result_calls on this row. "
|
||||
f"That is how `cleared_threshold` read for its whole life (#3670): a "
|
||||
f"figure presented as an independent measurement that a reader can "
|
||||
f"compute from the two numbers next to it. Either it is a tautology, "
|
||||
f"or this fixture happens to make it look like one — check which "
|
||||
f"before adding an exemption."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_near_miss_distribution_is_a_query_postgres_accepts(_dispose_engine):
|
||||
"""Integration, and NOT belt-and-braces on the unit tests above.
|
||||
|
||||
`near_misses` is a `percentile_cont(...) WITHIN GROUP` over a CASE
|
||||
expression, inside the same grouped aggregate that already carries four
|
||||
other CASEs. That is within one step of the shape that produced #2663 — a
|
||||
query the database rejected, swallowed by this module's broad `except`, so
|
||||
every counter read zero in production while the writes landed fine and the
|
||||
mocked tests passed. Only a real Postgres can say this parses, and if it
|
||||
does not, the symptom is silence rather than an error.
|
||||
|
||||
The numbers are chosen so a bar at 0.72 is visibly the wrong bar: three
|
||||
declines at 0.70, 0.71 and 0.7189, none of which a reader could see before.
|
||||
"""
|
||||
from sqlalchemy import delete
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.retrieval_log import RetrievalLog
|
||||
from scribe.services.retrieval_telemetry import (
|
||||
_insert_retrieval_log, retrieval_summary,
|
||||
)
|
||||
|
||||
UID = 990079
|
||||
for best in (0.70, 0.71, 0.7189):
|
||||
await _insert_retrieval_log(_build_payload(
|
||||
user_id=UID, source="pre_tool_rule", query="git push", threshold=0.72,
|
||||
limit=1, project_id=None, is_task=None, results=[],
|
||||
duration_ms=4.0, best_available=best,
|
||||
))
|
||||
# A call that DID show something. Its best-available equals its top score,
|
||||
# so including it would drag the distribution toward the scores the bar
|
||||
# already accepts — the population has to be the declines alone.
|
||||
await _insert_retrieval_log(_build_payload(
|
||||
user_id=UID, source="pre_tool_rule", query="curl", threshold=0.72,
|
||||
limit=1, project_id=None, is_task=None,
|
||||
results=[(0.88, _note(7))], duration_ms=4.0, best_available=0.88,
|
||||
))
|
||||
# An unmeasured decline, standing in for every row written before #3670.
|
||||
await _insert_retrieval_log(_build_payload(
|
||||
user_id=UID, source="pre_tool_rule", query="ls", threshold=0.72,
|
||||
limit=1, project_id=None, is_task=None, results=[], duration_ms=4.0,
|
||||
))
|
||||
# THE CASE WHOSE ABSENCE LET THIS GUARD PASS OVER BROKEN CODE (#3739).
|
||||
# A zero-result call whose zero was a REPEAT, not a rejection: the ranker
|
||||
# cleared the bar at 0.9 and the session had already been shown that rule,
|
||||
# so the arm dropped it in Python after the search. Without the suppression
|
||||
# arm of the predicate this row lands in the near-miss population and drags
|
||||
# `max` to 0.9 — above the very threshold the field is read against.
|
||||
await _insert_retrieval_log(_build_payload(
|
||||
user_id=UID, source="pre_tool_rule", query="git commit", threshold=0.72,
|
||||
limit=1, project_id=None, is_task=None, results=[], duration_ms=4.0,
|
||||
best_available=0.9, suppressed=1,
|
||||
))
|
||||
|
||||
try:
|
||||
out = await retrieval_summary(UID, days=30)
|
||||
assert out["read_failed"] is False, (
|
||||
"the aggregate did not execute — a rejected query here reads as "
|
||||
"zeros everywhere, which is #2663 exactly"
|
||||
)
|
||||
src = out["sources"]["pre_tool_rule"]
|
||||
assert src["calls"] == 6
|
||||
assert src["zero_result_calls"] == 5
|
||||
|
||||
nm = src["near_misses"]
|
||||
assert nm is not None, "the near-miss block did not survive the query"
|
||||
assert nm["measured_calls"] == 3, (
|
||||
"the population is declines the BAR caused, that recorded a score. "
|
||||
"Three qualify. Excluded: the unmeasured row (predates the column, "
|
||||
"not a scoreless decline), the call that showed something (its "
|
||||
"best-available is just its top score), and the REPEAT — a zero "
|
||||
"the reader caused, not the bar (#3739)"
|
||||
)
|
||||
assert nm["max"] == pytest.approx(0.7189, abs=1e-4), (
|
||||
"the closest thing the bar turned away — 0.7189 against a 0.72 "
|
||||
"threshold, which is the reading the whole field exists to give"
|
||||
)
|
||||
assert nm["max"] < 0.72, (
|
||||
"a rejection that outscores the bar is not a rejection. This is "
|
||||
"structural once the suppression arm is in the predicate: an "
|
||||
"above-bar candidate that was not excluded would have been "
|
||||
"RETURNED, so its call cannot be in this population (#3739)"
|
||||
)
|
||||
assert 0.70 <= nm["p50"] <= 0.7189
|
||||
finally:
|
||||
async with async_session() as s:
|
||||
await s.execute(delete(RetrievalLog).where(RetrievalLog.user_id == UID))
|
||||
await s.commit()
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Rule usage telemetry — the parts that need no database (milestone 333 step 1).
|
||||
|
||||
The round trip lives in `test_integration_backup_rule_usage_roundtrip.py`.
|
||||
What is here is the payload building and the zero shape: cheap, and the half
|
||||
where a mistake is silent rather than loud.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from scribe.models.rule_usage import PULLED, SURFACED, RuleUsageEvent
|
||||
from scribe.services import rule_usage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def captured(monkeypatch):
|
||||
"""Intercept the scheduler so the payload can be read without a loop.
|
||||
|
||||
Patching `_schedule` rather than `background.spawn` keeps the test on this
|
||||
module's own seam: what is under test is which rows get built, not whether
|
||||
the shared fire-and-forget machinery works — that has its own home.
|
||||
"""
|
||||
rows: list[list[dict]] = []
|
||||
monkeypatch.setattr(rule_usage, "_schedule", rows.append)
|
||||
return rows
|
||||
|
||||
|
||||
def test_a_surfacing_records_one_row_per_rule(captured):
|
||||
"""The arm shows a hint containing several rules at once; each needs its
|
||||
own row, because the readout is per rule."""
|
||||
rule_usage.record_rule_surfaced(
|
||||
user_id=7, rule_ids=[156, 157], source="write_path_rule"
|
||||
)
|
||||
[batch] = captured
|
||||
assert batch == [
|
||||
{"user_id": 7, "rule_id": 156, "event": SURFACED, "source": "write_path_rule"},
|
||||
{"user_id": 7, "rule_id": 157, "event": SURFACED, "source": "write_path_rule"},
|
||||
]
|
||||
|
||||
|
||||
def test_the_whole_hint_lands_as_one_batch(captured):
|
||||
"""One scheduled insert for the hint, not one per rule. A hint is a single
|
||||
decision and its rows should land together — a partial batch would read as
|
||||
a hint that surfaced fewer rules than it did."""
|
||||
rule_usage.record_rule_surfaced(
|
||||
user_id=7, rule_ids=[1, 2, 3], source="write_path_rule"
|
||||
)
|
||||
assert len(captured) == 1
|
||||
assert len(captured[0]) == 3
|
||||
|
||||
|
||||
def test_a_pull_records_one_row(captured):
|
||||
rule_usage.record_rule_pulled(user_id=7, rule_id=156, source="mcp_get_rule")
|
||||
assert captured == [
|
||||
[{"user_id": 7, "rule_id": 156, "event": PULLED, "source": "mcp_get_rule"}]
|
||||
]
|
||||
|
||||
|
||||
def test_an_actorless_event_is_still_recorded(captured):
|
||||
"""The arm fires from a hook that may carry no authenticated user. Dropping
|
||||
those would silently shrink the denominator the ratio divides by — the
|
||||
surfacings would vanish while any later pull still counted."""
|
||||
rule_usage.record_rule_surfaced(
|
||||
user_id=None, rule_ids=[156], source="write_path_rule"
|
||||
)
|
||||
assert captured[0][0]["user_id"] is None
|
||||
|
||||
|
||||
def test_an_empty_surfacing_builds_no_rows(captured):
|
||||
"""The arm can rank everything out — `exclude_rule_ids` drops what the
|
||||
session already holds. That is not a surfacing, and the empty batch is
|
||||
where `_schedule` returns early rather than opening a session to insert
|
||||
nothing."""
|
||||
rule_usage.record_rule_surfaced(user_id=7, rule_ids=[], source="write_path_rule")
|
||||
assert captured == [[]]
|
||||
|
||||
|
||||
def test_the_real_scheduler_returns_early_on_an_empty_batch():
|
||||
"""The guard itself, against the REAL `_schedule` the stub above replaces.
|
||||
|
||||
There is no running loop in a unit test, so `spawn` would be harmless
|
||||
anyway — but it would build a coroutine only to close it, and the point is
|
||||
that an empty batch never gets that far.
|
||||
"""
|
||||
rule_usage._schedule([]) # must not raise
|
||||
|
||||
|
||||
def test_a_bad_rule_id_is_dropped_not_raised(captured):
|
||||
"""Telemetry must never break the surface it observes. An unconvertible id
|
||||
is a bug somewhere upstream, and the right response is to lose the row and
|
||||
log it — not to take down the write-path hint."""
|
||||
rule_usage.record_rule_pulled(
|
||||
user_id=7, rule_id="not-an-int", source="mcp_get_rule" # type: ignore[arg-type]
|
||||
)
|
||||
assert captured == []
|
||||
|
||||
|
||||
def test_the_zero_readout_names_every_key():
|
||||
"""Callers render this shape unconditionally. Every rule in an existing
|
||||
install predates the table, so for a while "no events" is the NORMAL state
|
||||
— a missing key here would read as a broken readout on almost every row."""
|
||||
assert rule_usage.empty_rule_usage() == {
|
||||
"surfaced_count": 0,
|
||||
"ambient_count": 0,
|
||||
"pull_count": 0,
|
||||
"last_surfaced_at": None,
|
||||
"last_pulled_at": None,
|
||||
}
|
||||
|
||||
|
||||
def test_only_a_ranker_counts_as_ranked():
|
||||
"""The bulk surfaces are ambient; the write-path arm is the only chooser.
|
||||
|
||||
Inverted against the note twin on purpose (see the module docstring): the
|
||||
RARE half is the one that gets named, so a bulk surface added later and
|
||||
forgotten defaults to ambient — under-counting it — instead of defaulting
|
||||
to ranked and padding the pull-through denominator with surfacings nobody
|
||||
chose.
|
||||
"""
|
||||
assert not rule_usage.is_ambient("write_path_rule")
|
||||
for bulk in (
|
||||
"session_start", "list_always_on_rules", "enter_project",
|
||||
"get_project", "get_milestone", "start_planning", "get_task",
|
||||
):
|
||||
assert rule_usage.is_ambient(bulk), bulk
|
||||
# The safe default is the whole point of the inversion.
|
||||
assert rule_usage.is_ambient("some_surface_invented_next_year")
|
||||
|
||||
|
||||
def test_the_model_serialises_the_fields_the_ratio_needs():
|
||||
ev = RuleUsageEvent(
|
||||
user_id=7, rule_id=156, event=SURFACED, source="write_path_rule"
|
||||
)
|
||||
row = ev.to_dict()
|
||||
assert row["rule_id"] == 156
|
||||
assert row["event"] == SURFACED
|
||||
assert row["source"] == "write_path_rule"
|
||||
# created_at is server-defaulted, so it is None until the row is flushed —
|
||||
# `iso()` must tolerate that rather than raising on a fresh instance.
|
||||
assert row["created_at"] is None
|
||||
|
||||
|
||||
# ─── the readout (milestone 333 step 5) ──────────────────────────────────────
|
||||
# Integration: a real GROUP BY over a real table. Step 1 unit-tested the WRITE
|
||||
# path and the zero shape and left the aggregate uncovered, which only became
|
||||
# load-bearing when the rule list started rendering it.
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_usage_for_rules_aggregates_per_rule(_dispose_engine):
|
||||
from sqlalchemy import delete
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.rule_usage import RuleUsageEvent
|
||||
|
||||
async with async_session() as s:
|
||||
s.add_all([
|
||||
RuleUsageEvent(user_id=990020, rule_id=6001,
|
||||
event=SURFACED, source="write_path_rule"),
|
||||
RuleUsageEvent(user_id=990020, rule_id=6001,
|
||||
event=SURFACED, source="write_path_rule"),
|
||||
RuleUsageEvent(user_id=990020, rule_id=6001,
|
||||
event=PULLED, source="mcp_get_rule"),
|
||||
RuleUsageEvent(user_id=990020, rule_id=6002,
|
||||
event=SURFACED, source="write_path_rule"),
|
||||
])
|
||||
await s.commit()
|
||||
try:
|
||||
out = await rule_usage.usage_for_rules([6001, 6002, 6003])
|
||||
|
||||
assert out[6001]["surfaced_count"] == 2
|
||||
assert out[6001]["pull_count"] == 1
|
||||
assert out[6001]["last_surfaced_at"] is not None
|
||||
assert out[6001]["last_pulled_at"] is not None
|
||||
|
||||
# Surfaced twice as often as it was opened — never, in this case.
|
||||
assert out[6002]["surfaced_count"] == 1
|
||||
assert out[6002]["pull_count"] == 0
|
||||
assert out[6002]["last_pulled_at"] is None
|
||||
|
||||
# A rule with NO events still comes back, zero-filled. The caller must
|
||||
# never have to tell "no events" from "not in the result" — and on any
|
||||
# existing install that is nearly every rule.
|
||||
assert out[6003] == rule_usage.empty_rule_usage()
|
||||
|
||||
# Nothing ambient in this fixture, so the ambient bucket stays empty
|
||||
# rather than absorbing the ranked hits.
|
||||
assert out[6001]["ambient_count"] == 0
|
||||
finally:
|
||||
async with async_session() as s:
|
||||
await s.execute(
|
||||
delete(RuleUsageEvent).where(RuleUsageEvent.user_id == 990020)
|
||||
)
|
||||
await s.commit()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_usage_for_rules_on_an_empty_id_list_asks_the_database_nothing(
|
||||
_dispose_engine,
|
||||
):
|
||||
"""The list route calls this with whatever the page holds, which on an
|
||||
empty topic is nothing. An unguarded `IN ()` is both a pointless round trip
|
||||
and, on some drivers, a syntax error."""
|
||||
assert await rule_usage.usage_for_rules([]) == {}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_preloaded_rule_does_not_read_as_a_ranked_surfacing(_dispose_engine):
|
||||
"""The split that makes the always-on set judgeable (#3473).
|
||||
|
||||
A resident rule is delivered every session by a surface that chose
|
||||
nothing. Counting those as `surfaced_count` would rank the always-on set
|
||||
as the most-surfaced rules in the install purely for being resident — and
|
||||
the badge's "shown often, opened never → dead weight" reading, which is
|
||||
the whole reason the counter exists, would then be exactly backwards.
|
||||
"""
|
||||
from sqlalchemy import delete
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.rule_usage import RuleUsageEvent
|
||||
|
||||
async with async_session() as s:
|
||||
s.add_all([
|
||||
# Delivered by the preload three times over: ambient, all of it.
|
||||
RuleUsageEvent(user_id=990021, rule_id=6101,
|
||||
event=SURFACED, source="session_start"),
|
||||
RuleUsageEvent(user_id=990021, rule_id=6101,
|
||||
event=SURFACED, source="list_always_on_rules"),
|
||||
RuleUsageEvent(user_id=990021, rule_id=6101,
|
||||
event=SURFACED, source="enter_project"),
|
||||
# ...and once by the arm, which DID choose it.
|
||||
RuleUsageEvent(user_id=990021, rule_id=6101,
|
||||
event=SURFACED, source="write_path_rule"),
|
||||
# Opened once after a hint and once from the list: pulls are pulls
|
||||
# however the rule was found, so both land in the one counter.
|
||||
RuleUsageEvent(user_id=990021, rule_id=6101,
|
||||
event=PULLED, source="mcp_get_rule"),
|
||||
RuleUsageEvent(user_id=990021, rule_id=6101,
|
||||
event=PULLED, source="rest_rule"),
|
||||
])
|
||||
await s.commit()
|
||||
try:
|
||||
out = await rule_usage.usage_for_rules([6101])
|
||||
|
||||
assert out[6101]["surfaced_count"] == 1, "only the arm chose this rule"
|
||||
assert out[6101]["ambient_count"] == 3, "three bulk deliveries"
|
||||
# Both PULLED rows accumulate — the loop ADDS rather than assigns, so a
|
||||
# rule opened after a hint and again from the list reports two, not one.
|
||||
assert out[6101]["pull_count"] == 2
|
||||
assert out[6101]["last_pulled_at"] is not None
|
||||
finally:
|
||||
async with async_session() as s:
|
||||
await s.execute(
|
||||
delete(RuleUsageEvent).where(RuleUsageEvent.user_id == 990021)
|
||||
)
|
||||
await s.commit()
|
||||
@@ -0,0 +1,158 @@
|
||||
"""The SessionStart hook clears the rule ledger exactly when context dies (#3749).
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
The prior-art and tool-rule hooks record every rule id they have named in
|
||||
`<state>/<sid>.rules.ids` and hand it back as `exclude_rule_ids`, so a rule is
|
||||
surfaced once per session and then goes quiet. That is correct while the
|
||||
session still holds what it was told.
|
||||
|
||||
A compaction breaks that assumption in the worst available way: it summarizes
|
||||
the earlier injections out of context and does not touch the filesystem. The
|
||||
rule ends up absent from context AND still excluded — unreachable for the rest
|
||||
of the session. The compaction banner tells the model to re-pull its
|
||||
*always-on* rules, but a rule an arm surfaced is conditional and is not in that
|
||||
set, so it has no other way back. The rules most likely to be in that state are
|
||||
the ones that fire most often.
|
||||
|
||||
WHAT THIS PINS
|
||||
|
||||
Not "the ledger is cleared" — that would pass against a hook which deletes it
|
||||
on every source, and deleting on `resume` is its own defect: the context was
|
||||
genuinely restored there, so re-surfacing every rule is the mirror error.
|
||||
|
||||
What is pinned is the DISCRIMINATION. The whole source table is asserted in one
|
||||
statement, so a blanket delete (all False) and a no-op (all True) both fail,
|
||||
and neither can be made to pass by editing one case.
|
||||
|
||||
Runs the real shell against a temp TMPDIR, like the after-write hook's tests.
|
||||
Deliberately with no SCRIBE_URL/SCRIBE_TOKEN in the environment: clearing the
|
||||
ledger is local, keyless and networkless, and must still happen on an instance
|
||||
that is unreachable or unconfigured.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
|
||||
HOOK = PLUGIN / "hooks" / "scribe_session_context.sh"
|
||||
|
||||
|
||||
def _env(tmp_path):
|
||||
"""Near-namesake of test_after_write_hook's `_env`, and deliberately not it:
|
||||
that one needs git and curl and SUPPLIES credentials, because the behaviour
|
||||
it tests is a network round-trip. This one must prove the opposite — that
|
||||
the clear happens with no credentials and no network at all — so sharing a
|
||||
helper would mean testing this case in an environment that cannot show it.
|
||||
"""
|
||||
for tool in ("jq", "bash"):
|
||||
if shutil.which(tool) is None:
|
||||
pytest.skip(f"hook runtime tool {tool!r} not installed")
|
||||
# No SCRIBE_URL / SCRIBE_TOKEN on purpose — see the module docstring.
|
||||
return {"PATH": os.environ["PATH"], "TMPDIR": str(tmp_path),
|
||||
"HOME": str(tmp_path)}
|
||||
|
||||
|
||||
def _ledger(tmp_path, sid: str, name: str = "rules.ids") -> Path:
|
||||
d = tmp_path / "scribe-priorart"
|
||||
d.mkdir(exist_ok=True)
|
||||
f = d / f"{sid}.{name}"
|
||||
f.write_text("156\n168\n")
|
||||
return f
|
||||
|
||||
|
||||
def _fire(source: str, sid: str, env) -> None:
|
||||
subprocess.run(
|
||||
["bash", str(HOOK)],
|
||||
input=json.dumps({"source": source, "session_id": sid}),
|
||||
capture_output=True, text=True, env=env, timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def test_the_rules_ledger_survives_exactly_when_the_context_does(tmp_path):
|
||||
"""The whole source table, in one assertion, so it cannot be half-satisfied.
|
||||
|
||||
`startup` is listed even though it is a no-op against a session id that has
|
||||
never been seen: it is asserted here so that a future change which starts
|
||||
clearing indiscriminately fails on a case somebody would otherwise call
|
||||
harmless.
|
||||
"""
|
||||
env = _env(tmp_path)
|
||||
survived = {}
|
||||
for source in ("compact", "clear", "resume", "startup"):
|
||||
sid = f"sess-{source}"
|
||||
ledger = _ledger(tmp_path, sid)
|
||||
_fire(source, sid, env)
|
||||
survived[source] = ledger.exists()
|
||||
|
||||
assert survived == {
|
||||
"compact": False,
|
||||
"clear": False,
|
||||
"resume": True,
|
||||
"startup": True,
|
||||
}, (
|
||||
f"got {survived}. A rule surfaced before a compaction is summarized "
|
||||
f"out of context while its id stays on the exclusion ledger, so it "
|
||||
f"becomes unreachable for the rest of the session — that is what the "
|
||||
f"compact/clear cases prevent. The resume case is the other half: the "
|
||||
f"context came back intact there, and re-surfacing every rule after a "
|
||||
f"restore that lost nothing is the same defect from the other side. "
|
||||
f"All-False means something is deleting unconditionally; all-True "
|
||||
f"means the clear never runs."
|
||||
)
|
||||
|
||||
|
||||
def test_only_the_rule_ledger_is_cleared_and_the_note_ledgers_are_left(tmp_path):
|
||||
"""Scope, asserted rather than described.
|
||||
|
||||
The same directory holds `.ids`, `.sync.ids` and `.derive.ids` for the note
|
||||
arms. Whether a surfaced NOTE should come back after a compaction is a
|
||||
different question with a different answer, and it is not being answered.
|
||||
A `rm` glob over `<sid>.*` would pass every assertion in the test above
|
||||
while silently deciding it.
|
||||
"""
|
||||
env = _env(tmp_path)
|
||||
sid = "sess-scope"
|
||||
rules = _ledger(tmp_path, sid, "rules.ids")
|
||||
notes = _ledger(tmp_path, sid, "ids")
|
||||
sync = _ledger(tmp_path, sid, "sync.ids")
|
||||
derive = _ledger(tmp_path, sid, "derive.ids")
|
||||
|
||||
_fire("compact", sid, env)
|
||||
|
||||
assert not rules.exists(), "the rule ledger should have been cleared"
|
||||
assert notes.exists() and sync.exists() and derive.exists(), (
|
||||
"a note ledger was cleared too. The note arms were deliberately left "
|
||||
"out of #3749 — clearing them is a decision about a different surface, "
|
||||
"and a glob that takes them along makes it by accident."
|
||||
)
|
||||
|
||||
|
||||
def test_a_compact_without_a_session_id_is_survivable(tmp_path):
|
||||
"""Defensive, because this hook's contract is fail-open.
|
||||
|
||||
An event with no `session_id` names no ledger. The hook must not error, and
|
||||
must not fall back to a wildcard — clearing every session's ledger on the
|
||||
machine because this one event was malformed is the worst available
|
||||
reading of "best effort".
|
||||
"""
|
||||
env = _env(tmp_path)
|
||||
other = _ledger(tmp_path, "someone-elses-session")
|
||||
|
||||
proc = subprocess.run(
|
||||
["bash", str(HOOK)],
|
||||
input=json.dumps({"source": "compact"}),
|
||||
capture_output=True, text=True, env=env, timeout=30,
|
||||
)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert other.exists(), (
|
||||
"an event with no session id cleared a ledger belonging to a different "
|
||||
"session"
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""`/api/version` reports three values, and never folds them together.
|
||||
|
||||
WHAT THIS IS ABOUT (rule 149). Until 2026-08-31 the endpoint returned
|
||||
`{"version": "main"}` — CI set `BUILD_VERSION` to the CHANNEL, so a running
|
||||
instance answered the question "which build are you?" with the name of a
|
||||
branch. The cost was concrete rather than theoretical: during #3244's live
|
||||
acceptance a deploy was behaving as though it held older code, and the one
|
||||
endpoint whose job is to settle that could not.
|
||||
|
||||
The three values answer different questions and so cannot be one value:
|
||||
|
||||
version the NAME, from COMMIT time — "is this the same code?"
|
||||
build the ORDERING KEY, BUILD time — "may this be installed over that?"
|
||||
channel its own field — "which line is this?"
|
||||
|
||||
These pin the SHAPE the lanes emit, not the values — a test asserting today's
|
||||
timestamp would fail tomorrow, and one asserting the format catches the thing
|
||||
that actually breaks: a channel creeping back into the name, or an ordering
|
||||
key that is not orderable.
|
||||
"""
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
CI = pathlib.Path(__file__).resolve().parents[1] / ".forgejo/workflows/ci.yml"
|
||||
|
||||
# The NAME's shape: four dot-separated numeric fields, zero-padded, and
|
||||
# nothing else. A channel token anywhere in here is the bug this file exists
|
||||
# to prevent.
|
||||
NAME_RE = re.compile(r"^\d{4}\.\d{2}\.\d{2}\.\d{4}$")
|
||||
|
||||
|
||||
_ENV_KEYS = ("APP_VERSION", "APP_BUILD_KEY", "APP_CHANNEL", "APP_COMMIT")
|
||||
|
||||
|
||||
def _version_payload(env: dict) -> dict:
|
||||
"""The real payload builder, under a controlled environment.
|
||||
|
||||
Calls `build_version_payload` rather than the route: the payload is the
|
||||
behaviour, and reaching it through an app and a request context would
|
||||
make these tests depend on app startup to assert a dict. The route is a
|
||||
one-line `jsonify` wrapper over this.
|
||||
"""
|
||||
from scribe.routes.api import build_version_payload
|
||||
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
# patch.dict cannot REMOVE, and "absent" is exactly what several of
|
||||
# these assert — so anything the caller left out is cleared.
|
||||
for key in _ENV_KEYS:
|
||||
if key not in env:
|
||||
os.environ.pop(key, None)
|
||||
return build_version_payload()
|
||||
|
||||
|
||||
def test_the_three_values_are_three_fields():
|
||||
"""The headline. One field cannot answer three questions, and the failure
|
||||
mode of trying is silent: the string looks plausible and orders wrong."""
|
||||
out = _version_payload({
|
||||
"APP_VERSION": "2026.08.31.0403",
|
||||
"APP_BUILD_KEY": "3505443",
|
||||
"APP_CHANNEL": "stable",
|
||||
"APP_COMMIT": "b267037",
|
||||
})
|
||||
assert out["version"] == "2026.08.31.0403"
|
||||
assert out["build"] == 3505443
|
||||
assert out["channel"] == "stable"
|
||||
assert out["commit"] == "b267037"
|
||||
|
||||
|
||||
def test_the_channel_is_never_inside_the_name():
|
||||
"""The regression itself. `{"version": "main"}` is what this catches."""
|
||||
out = _version_payload({
|
||||
"APP_VERSION": "2026.08.31.0403", "APP_CHANNEL": "stable",
|
||||
})
|
||||
assert NAME_RE.match(out["version"]), (
|
||||
f"the version name is {out['version']!r} — not YYYY.MM.DD.HHMM. A "
|
||||
f"channel or branch name here is the 2026-08-31 bug returning."
|
||||
)
|
||||
assert "stable" not in out["version"]
|
||||
|
||||
|
||||
def test_the_ordering_key_is_an_INTEGER():
|
||||
"""A string ordering key is how a comparison silently becomes
|
||||
lexicographic — "9" > "10" — which reads fine and orders wrong."""
|
||||
out = _version_payload({"APP_VERSION": "x", "APP_BUILD_KEY": "3505443"})
|
||||
assert isinstance(out["build"], int)
|
||||
assert not isinstance(out["build"], bool)
|
||||
|
||||
|
||||
def test_unknown_values_are_ABSENT_not_empty():
|
||||
"""A local build genuinely has no ordering key and no channel. Emitting
|
||||
`""` or a placeholder would let it claim a position in an update order it
|
||||
is not part of; a reader must see "cannot be ordered", not zero."""
|
||||
out = _version_payload({"APP_VERSION": "dev"})
|
||||
assert out == {"version": "dev"}
|
||||
assert "build" not in out and "channel" not in out and "commit" not in out
|
||||
|
||||
|
||||
def test_an_empty_env_var_counts_as_absent():
|
||||
"""Docker sets an ARG with no default to the empty string, so "unset" and
|
||||
"set to nothing" both reach the handler as ''."""
|
||||
out = _version_payload({
|
||||
"APP_VERSION": "dev", "APP_CHANNEL": "", "APP_BUILD_KEY": "",
|
||||
"APP_COMMIT": " ",
|
||||
})
|
||||
assert out == {"version": "dev"}
|
||||
|
||||
|
||||
def test_a_malformed_ordering_key_is_dropped_not_passed_through():
|
||||
"""A reader that cannot order is correct; one that orders on garbage is
|
||||
not. Dropping it degrades to "unorderable", which is a state the caller
|
||||
already has to handle."""
|
||||
out = _version_payload({"APP_VERSION": "dev", "APP_BUILD_KEY": "main"})
|
||||
assert "build" not in out
|
||||
|
||||
|
||||
def test_the_channel_is_reported_verbatim():
|
||||
"""Never validated against an enum — a build claiming something
|
||||
unexpected is better shown than dropped (rule 149)."""
|
||||
out = _version_payload({"APP_VERSION": "dev", "APP_CHANNEL": "canary"})
|
||||
assert out["channel"] == "canary"
|
||||
|
||||
|
||||
# ── The lane, as CI actually writes it ─────────────────────────────────
|
||||
|
||||
def test_ci_does_not_stamp_the_channel_as_the_version():
|
||||
"""The bug lived in the workflow, not the handler. A correct handler fed
|
||||
`BUILD_VERSION=main` still reports a branch name."""
|
||||
text = CI.read_text()
|
||||
assert "BUILD_VERSION=${{ steps.tags.outputs.build_name }}" in text, (
|
||||
"CI no longer passes the derived NAME as BUILD_VERSION. If it is "
|
||||
"passing a branch or channel again, /api/version is lying."
|
||||
)
|
||||
for wrong in ('BUILD_VERSION="main"', 'BUILD_VERSION="dev"'):
|
||||
assert wrong not in text, (
|
||||
f"CI sets {wrong} — that is the channel in the version field, "
|
||||
f"which is the 2026-08-31 regression."
|
||||
)
|
||||
|
||||
|
||||
def test_ci_derives_the_name_from_COMMIT_time_and_the_key_from_BUILD_time():
|
||||
"""The two clocks are deliberate and easy to "tidy" into one.
|
||||
|
||||
The name must come from the commit so two lanes building one source agree;
|
||||
the key must come from the build so it cannot go backwards when an older
|
||||
commit is rebuilt. Collapsing them breaks whichever question loses.
|
||||
"""
|
||||
text = CI.read_text()
|
||||
assert "git log --format=%ct -1 HEAD" in text, (
|
||||
"the version NAME is no longer derived from commit time — two lanes "
|
||||
"building the same commit will now report different strings"
|
||||
)
|
||||
assert "$(date -u +%s) - 1577836800" in text, (
|
||||
"the ORDERING KEY is no longer minutes-since-2020 from build time; "
|
||||
"if it now comes from the commit it can go backwards on a rebuild"
|
||||
)
|
||||
|
||||
|
||||
def test_ci_passes_all_three_plus_the_commit():
|
||||
text = CI.read_text()
|
||||
for arg in ("BUILD_KEY=", "BUILD_CHANNEL=", "BUILD_COMMIT="):
|
||||
assert arg in text, f"CI no longer passes {arg} to the image build"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("commit_epoch,expected", [
|
||||
# Midnight, where a naive formatter drops the leading zeros and yields
|
||||
# "2026.01.05.0" — rule 149 names this case specifically.
|
||||
(datetime(2026, 1, 5, 0, 0, tzinfo=timezone.utc), "2026.01.05.0000"),
|
||||
(datetime(2026, 1, 5, 0, 7, tzinfo=timezone.utc), "2026.01.05.0007"),
|
||||
(datetime(2026, 12, 31, 23, 59, tzinfo=timezone.utc), "2026.12.31.2359"),
|
||||
(datetime(2026, 8, 31, 4, 3, tzinfo=timezone.utc), "2026.08.31.0403"),
|
||||
])
|
||||
def test_the_name_format_zero_pads_every_field(commit_epoch, expected):
|
||||
"""`date -u +%Y.%m.%d.%H%M` is what CI runs; this pins what that must
|
||||
produce, so a reformat that loses zero-padding fails here rather than in
|
||||
a comparison months later."""
|
||||
assert commit_epoch.strftime("%Y.%m.%d.%H%M") == expected
|
||||
assert NAME_RE.match(expected)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""The app must SAY what it is running, and must not lie when it cannot find out.
|
||||
|
||||
There is no frontend test runner in this repo, so these are source-inspection
|
||||
guards in the unit lane — the same idiom `check_plugin.py` uses on the hook
|
||||
shells. They are deliberately few and deliberately about ONE property each,
|
||||
because a grep-shaped test that asserts a whole file's contents fails on every
|
||||
refactor and gets deleted.
|
||||
|
||||
WHY THIS FILE EXISTS. #3298: with a deploy misbehaving, nothing on the instance
|
||||
could say which commit was serving it, and the one endpoint whose job that is
|
||||
answered with the name of a branch. The value was fixed then. This is the other
|
||||
half — the value reaching a person — and #3127 checklist 12 is specific about
|
||||
the way it goes wrong: *never let a blank stand in for `unknown`*. A readout
|
||||
that renders a plausible value it never received is worse than one that renders
|
||||
nothing, because it ends the investigation instead of starting it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend" / "src"
|
||||
|
||||
|
||||
def test_something_actually_reads_the_version_endpoint():
|
||||
"""The endpoint is not enough; something must ask it.
|
||||
|
||||
`/api/version` answered correctly for weeks with no caller — an endpoint
|
||||
reachable only by someone who already knew to curl it. Rule 27: a
|
||||
capability with no surface the operator can touch is not shipped.
|
||||
"""
|
||||
hits = [p for p in FRONTEND.rglob("*.ts") if "/api/version" in p.read_text()]
|
||||
assert hits, "nothing under frontend/src fetches /api/version"
|
||||
|
||||
|
||||
def test_the_footer_does_not_default_to_a_plausible_version():
|
||||
"""The regression this readout was built to remove.
|
||||
|
||||
`appVersion` used to start life as the literal `"dev"` and the fetch
|
||||
swallowed its own failure, so an instance that could not answer rendered
|
||||
exactly what a healthy local build renders. Two very different states, one
|
||||
string, and no way to tell them apart from the page.
|
||||
|
||||
Pinned as "the ref does not start at a version-shaped literal" rather than
|
||||
as an exact initialiser, so a later refactor can change how the state is
|
||||
held without failing here — what must not come back is the plausible
|
||||
default.
|
||||
"""
|
||||
app = (FRONTEND / "App.vue").read_text()
|
||||
match = re.search(r"const appVersion = ref[^;]*;", app)
|
||||
assert match, "App.vue no longer declares appVersion — update this guard"
|
||||
decl = match.group(0)
|
||||
assert '"dev"' not in decl and "'dev'" not in decl, (
|
||||
f"appVersion defaults to a version-shaped literal: {decl}\n"
|
||||
"A failed fetch would render as a real-looking version (#3127 "
|
||||
"checklist 12). Start from a not-answered-yet value instead."
|
||||
)
|
||||
|
||||
|
||||
def test_optional_version_fields_are_read_by_absence_not_falsiness():
|
||||
"""`build` is a number and 0 is a legitimate ordering key.
|
||||
|
||||
The payload omits what it does not know rather than sending `""` or `0`, so
|
||||
the renderer's job is to distinguish ABSENT from present. `||` cannot: it
|
||||
would report a real `build` of 0 as unknown, and it is the form a person
|
||||
reaches for by habit. `??` is the correct one, which is why this pins the
|
||||
operator rather than the rendered output.
|
||||
"""
|
||||
view = (FRONTEND / "views" / "SettingsView.vue").read_text()
|
||||
for field in ("channel", "build"):
|
||||
assert f'versionInfo.{field} ?? "unknown"' in view, (
|
||||
f"the {field} readout must use `?? \"unknown\"`, never `|| \"unknown\"` — "
|
||||
"an absent field and a falsy one are different answers"
|
||||
)
|
||||
|
||||
|
||||
def test_the_version_request_carries_a_deadline():
|
||||
"""Rule 156. A wait with no deadline cannot report that it failed.
|
||||
|
||||
This readout is consulted when an instance is misbehaving, which is exactly
|
||||
when it may never answer. Without a deadline the surface sits on "still
|
||||
loading" forever — the blank standing in for `unknown` again, arrived at
|
||||
from the other direction.
|
||||
"""
|
||||
src = (FRONTEND / "api" / "version.ts").read_text()
|
||||
assert "timeoutMs" in src, "the version fetch must pass a deadline"
|
||||
@@ -25,7 +25,13 @@ def _snippet_item(nid, title, user_id=1):
|
||||
|
||||
|
||||
def _cfg(**over):
|
||||
base = {"enabled": True, "threshold": 0.68, "top_k": 3}
|
||||
# `rule_threshold` is the standing-rule arm's own bar (milestone 333 step
|
||||
# 4). It belongs in the stand-in even though most tests here never reach
|
||||
# that arm: the arm reads it while BUILDING its search arguments, so a
|
||||
# missing key raises inside its fail-open except and turns the arm into a
|
||||
# silent no-op — which is indistinguishable from it working and finding
|
||||
# nothing.
|
||||
base = {"enabled": True, "threshold": 0.68, "top_k": 3, "rule_threshold": 0.72}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
@@ -354,10 +360,18 @@ async def test_telemetry_uses_its_own_source():
|
||||
patch.object(pc, "record_retrieval", rec), \
|
||||
patch.object(pc, "owner_names_for", AsyncMock(return_value={})):
|
||||
await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE, project_id=4)
|
||||
rec.assert_called_once()
|
||||
assert rec.call_args.kwargs["source"] == "write_path"
|
||||
assert rec.call_args.kwargs["source"] != "auto_inject"
|
||||
assert rec.call_args.kwargs["project_id"] == 4
|
||||
sources = [c.kwargs["source"] for c in rec.call_args_list]
|
||||
assert sources.count("write_path") == 1, sources
|
||||
assert "auto_inject" not in sources
|
||||
|
||||
note_arm = next(c for c in rec.call_args_list if c.kwargs["source"] == "write_path")
|
||||
assert note_arm.kwargs["project_id"] == 4
|
||||
|
||||
# The rule arm rides along on the same hint and logs its own call even when
|
||||
# it finds nothing (#3497). This assertion used to be `assert_called_once`,
|
||||
# which passed only because that row was never written — the test encoded
|
||||
# the defect. The second row is the point of having two sources.
|
||||
assert "write_path_rule" in sources, sources
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -426,6 +440,74 @@ async def test_writepath_threshold_is_operator_tunable_and_clamped():
|
||||
assert (await _cfg_with("banana"))["threshold"] == pc.WRITEPATH_DEFAULT_THRESHOLD
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_rule_arm_has_its_own_tunable_bar():
|
||||
"""Rule #25 again, for the THIRD corpus (milestone 333 step 4).
|
||||
|
||||
Separate from the code threshold above and separately settable, because the
|
||||
two are measured against different things: 0.68 was derived from code
|
||||
against note PROSE (#2223), and rules are short imperative technical
|
||||
English — a more homogeneous corpus whose noise floor sits higher.
|
||||
"""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
async def _cfg_with(raw):
|
||||
stored = {pc.RULEHINT_THRESHOLD_KEY: raw}
|
||||
with patch.object(pc, "get_setting",
|
||||
AsyncMock(side_effect=lambda uid, k, d: stored.get(k, d))):
|
||||
return await pc.get_writepath_config(1)
|
||||
|
||||
assert (await _cfg_with("0.8"))["rule_threshold"] == 0.8
|
||||
assert (await _cfg_with("5"))["rule_threshold"] == 1.0
|
||||
assert (await _cfg_with("-3"))["rule_threshold"] == 0.0
|
||||
# Garbage falls back to the default, not to 0.0 — which on THIS arm would
|
||||
# attach a standing rule to every write in the session.
|
||||
assert (await _cfg_with("banana"))["rule_threshold"] == pc.RULEHINT_DEFAULT_THRESHOLD
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_two_write_path_bars_are_independent():
|
||||
"""The split, asserted. Setting one must not move the other — the failure
|
||||
that would silently undo this step is a config assembler that reads one key
|
||||
into both fields."""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
stored = {pc.WRITEPATH_THRESHOLD_KEY: "0.90", pc.RULEHINT_THRESHOLD_KEY: "0.61"}
|
||||
with patch.object(pc, "get_setting",
|
||||
AsyncMock(side_effect=lambda uid, k, d: stored.get(k, d))):
|
||||
cfg = await pc.get_writepath_config(1)
|
||||
|
||||
assert cfg["threshold"] == 0.90
|
||||
assert cfg["rule_threshold"] == 0.61
|
||||
|
||||
|
||||
def test_the_rule_bar_defaults_above_the_code_bar():
|
||||
"""Not a number check — a DIRECTION check, and the only part of the default
|
||||
that is defensible without one instance's histogram (rule 115).
|
||||
|
||||
The eligible rule corpus is orders of magnitude smaller than the note
|
||||
corpus, so a top-k over it always returns something and a bar calibrated
|
||||
for best-of-thousands is cleared by best-of-forty as arithmetic. Rules are
|
||||
also more homogeneous than note prose, so their noise floor is higher. Both
|
||||
facts point the same way: this bar must sit ABOVE the one it inherited.
|
||||
|
||||
Pinned as an inequality so tuning the value stays free while inverting the
|
||||
relationship — which would silently reinstate #3311 — does not.
|
||||
"""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
assert pc.RULEHINT_DEFAULT_THRESHOLD > pc.WRITEPATH_DEFAULT_THRESHOLD
|
||||
|
||||
|
||||
def test_the_rule_arm_asks_for_one_rule_not_two():
|
||||
"""With a corpus this small, top-k does as much damage as the threshold:
|
||||
k=2 over a few dozen candidates means the second line is almost always the
|
||||
second-best noise, carrying the same confident framing as the first."""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
assert pc.RULEHINT_LIMIT == 1
|
||||
|
||||
|
||||
# --- the minimum-substance floor on the semantic arm (#2223) ------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -867,14 +949,6 @@ def test_hook_skips_prose_and_data_files():
|
||||
assert '/scribe_defs.sh"' in src # sourced, not copied
|
||||
|
||||
|
||||
def test_plugin_version_bumped_with_the_hook():
|
||||
"""The #1040 lesson: a plugin change clients can't see is a change that didn't
|
||||
ship."""
|
||||
manifest = json.loads((PLUGIN / ".claude-plugin" / "plugin.json").read_text())
|
||||
version = tuple(int(p) for p in manifest["version"].split("."))
|
||||
assert version >= (0, 1, 31)
|
||||
|
||||
|
||||
def test_hook_keeps_sync_and_reuse_dedup_apart():
|
||||
"""#2708's dedup audit, pinned: the hook holds TWO per-session id files and
|
||||
feeds each its own class — sync ids (snippets recording the edited file) to
|
||||
@@ -1452,3 +1526,84 @@ def test_hook_keeps_the_rule_channel_apart_from_the_other_three():
|
||||
assert "(.rule_ids // [])[]?" in src # its own write-back
|
||||
# And it rides the same request as the rest, not a second round trip.
|
||||
assert "${rule_exclude_q}" in src
|
||||
|
||||
|
||||
# ── `best_available` describes the BAR, not this arm's own second filter ──────
|
||||
#
|
||||
# #3739 fixed the rule arms: a record the reader had already been shown was
|
||||
# being logged as something the ranker turned away, which made the near-miss
|
||||
# distribution report scores ABOVE the very threshold it is read against.
|
||||
#
|
||||
# The fix keyed on `suppressed_count`, and its NULL branch was justified by
|
||||
# "null means the caller passed its exclusions INTO the search, so the reported
|
||||
# score is already post-exclusion". That is true of auto_inject and reuse_slot.
|
||||
# It is NOT true here: this is the one note arm that filters twice. `exclude_ids`
|
||||
# takes `seen - pulled_seen` into the search, but the pulled-and-seen ids stay in
|
||||
# the query on purpose (the arm's query doubles as the resemblance test) and are
|
||||
# dropped afterwards in Python.
|
||||
#
|
||||
# Live proof, on the first read after that fix shipped: write_path's near-miss
|
||||
# max was 0.822 while the lowest score it ever RETURNED was 0.6857 — a
|
||||
# "rejection" that beat every acceptance.
|
||||
|
||||
|
||||
def _search_reporting(score, note):
|
||||
"""A stand-in search that fills `report` the way the real one does.
|
||||
|
||||
It ignores `exclude_ids`, which is exactly the condition being reproduced:
|
||||
a record that is in `seen` comes back from the search anyway. In production
|
||||
that happens because `pulled_seen` is deliberately left in the query; here
|
||||
it needs no ledger, and the arm's handling is the same either way.
|
||||
"""
|
||||
async def _search(uid, q, **kw):
|
||||
report = kw.get("report")
|
||||
if report is not None:
|
||||
report["best_available_score"] = score
|
||||
return [(score, note)]
|
||||
return _search
|
||||
|
||||
|
||||
async def _write_path_row(rec, **kwargs):
|
||||
from scribe.services import plugin_context as pc
|
||||
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
||||
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
|
||||
patch.object(pc, "record_retrieval", rec), \
|
||||
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \
|
||||
patch.object(pc, "semantic_search_notes",
|
||||
_search_reporting(0.9, fake_note(
|
||||
id=7, title="scored", user_id=1, note_type="snippet"))):
|
||||
await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE, **kwargs)
|
||||
return next(c for c in rec.call_args_list
|
||||
if c.kwargs["source"] == "write_path")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_record_this_arm_withheld_itself_is_not_a_near_miss():
|
||||
"""The defect: the row's count is POST this arm's filter and the score was
|
||||
captured PRE it, so a withheld record is indistinguishable from one the bar
|
||||
rejected — while scoring higher than anything the bar ever let through."""
|
||||
rec = MagicMock()
|
||||
row = await _write_path_row(rec, exclude_ids=[7])
|
||||
|
||||
assert row.kwargs["results"] == [], "the hit was withheld, so nothing shown"
|
||||
assert row.kwargs["best_available"] is None, (
|
||||
"a 0.9 record this arm withheld itself was reported as the best thing "
|
||||
"the THRESHOLD turned away. It would read as a bar set far too high "
|
||||
"when the bar never rejected it at all (#3739)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_call_that_withheld_nothing_still_reports_what_the_bar_refused():
|
||||
"""The other half, and what stops the fix being 'never report it'.
|
||||
|
||||
Without this, setting `best_available=None` unconditionally passes the test
|
||||
above while deleting the measurement #3670 was built for.
|
||||
"""
|
||||
rec = MagicMock()
|
||||
row = await _write_path_row(rec)
|
||||
|
||||
assert row.kwargs["best_available"] == 0.9, (
|
||||
"nothing was withheld here, so the reported score describes the bar "
|
||||
"and must survive"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user