dev → main: auto-inject reads the conversation, startup names its failure, menu lines carry metadata, titles are names #183
@@ -0,0 +1,64 @@
|
|||||||
|
"""trigger_leaves_the_stored_title — a snippet's and lesson's title is its name (milestone 427)
|
||||||
|
|
||||||
|
Revision ID: 0108
|
||||||
|
Revises: 0107
|
||||||
|
Create Date: 2026-09-23
|
||||||
|
|
||||||
|
Snippets and lessons stored their title as `subject — trigger`, because the
|
||||||
|
join is what makes these kinds rank on the situation they apply to (#2485) and
|
||||||
|
the stored title WAS the embedded one. Every surface that shows a title then
|
||||||
|
showed the trigger too: menu lines, lists and search rows ran to 1,500–3,000
|
||||||
|
characters, and an injected repeat spent all of it again.
|
||||||
|
|
||||||
|
The trigger's home is `data` (decision #4157), and since this milestone the
|
||||||
|
join happens at embed time (`embeddings.document_title`). This revision
|
||||||
|
rewrites the rows already stored.
|
||||||
|
|
||||||
|
EXACT-SUFFIX, READ FROM THE ROW'S OWN MIRROR. A title is only rewritten when
|
||||||
|
it ends with `' — ' || <that row's trigger>`, so a subject that legitimately
|
||||||
|
contains an em dash is never cut, and a row whose title and mirror disagree —
|
||||||
|
hand-edited through the generic note door — is left alone. Such a row still
|
||||||
|
embeds correctly (`document_title` is idempotent) and merely shows its old
|
||||||
|
title; that is the right way round for a data migration to fail.
|
||||||
|
|
||||||
|
NO RE-EMBED, AND `updated_at` IS NOT TOUCHED. The embedded text is identical
|
||||||
|
before and after, so the vectors are already right. The startup backfill
|
||||||
|
re-embeds any row whose `updated_at` is newer than its vectors, and a raw
|
||||||
|
UPDATE that leaves `updated_at` alone is what keeps this from queueing the
|
||||||
|
whole snippet corpus for work that would produce the same numbers. There is
|
||||||
|
no database trigger on `updated_at`; it is set by the ORM only.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0108"
|
||||||
|
down_revision = "0107"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# kind → the `data` key its trigger is mirrored under (embeddings._TRIGGER_DATA_KEYS).
|
||||||
|
_KINDS = (("snippet", "when_to_use"), ("lesson", "when_to_apply"))
|
||||||
|
_SEP = " — "
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for note_type, key in _KINDS:
|
||||||
|
op.execute(f"""
|
||||||
|
UPDATE notes
|
||||||
|
SET title = btrim(left(title, length(title) - length('{_SEP}' || (data->>'{key}'))))
|
||||||
|
WHERE note_type = '{note_type}'
|
||||||
|
AND coalesce(btrim(data->>'{key}'), '') <> ''
|
||||||
|
AND right(title, length('{_SEP}' || (data->>'{key}'))) = '{_SEP}' || (data->>'{key}')
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Recompose, on the same condition inverted: only where the trigger is not
|
||||||
|
# already on the end, so a downgrade run twice cannot double it.
|
||||||
|
for note_type, key in _KINDS:
|
||||||
|
op.execute(f"""
|
||||||
|
UPDATE notes
|
||||||
|
SET title = title || '{_SEP}' || (data->>'{key}')
|
||||||
|
WHERE note_type = '{note_type}'
|
||||||
|
AND coalesce(btrim(data->>'{key}'), '') <> ''
|
||||||
|
AND right(title, length('{_SEP}' || (data->>'{key}'))) <> '{_SEP}' || (data->>'{key}')
|
||||||
|
""")
|
||||||
@@ -91,6 +91,9 @@ export interface SnippetListItem {
|
|||||||
* hit can be flagged as being in a DIFFERENT language than the file being
|
* hit can be flagged as being in a DIFFERENT language than the file being
|
||||||
* written, which is a shape to adapt rather than code to paste. */
|
* written, which is a shape to adapt rather than code to paste. */
|
||||||
language?: string;
|
language?: string;
|
||||||
|
/** When to reach for it, projected from the `data` mirror. The title is the
|
||||||
|
* name alone since milestone 427, so this is where the situation lives. */
|
||||||
|
when_to_use?: string;
|
||||||
/** Always present from the backend, zero-filled for records with no events. */
|
/** Always present from the backend, zero-filled for records with no events. */
|
||||||
usage?: SnippetUsage;
|
usage?: SnippetUsage;
|
||||||
/** Present on the detail record; the list feed carries it when a check has
|
/** Present on the detail record; the list feed carries it when a check has
|
||||||
|
|||||||
@@ -189,11 +189,18 @@ const onLocationInput = onSearchInput;
|
|||||||
|
|
||||||
onMounted(loadSnippets);
|
onMounted(loadSnippets);
|
||||||
|
|
||||||
/** Titles are stored as "name — when to reach for it"; split for display. */
|
/** A row's name and when-to-use. The title is the name alone since milestone
|
||||||
function splitTitle(title: string): { name: string; when: string } {
|
* 427 and the situation arrives as `when_to_use`; a title composed before then
|
||||||
const idx = title.indexOf(" — ");
|
* ("name — when to reach for it") is split, so either shape reads the same. */
|
||||||
if (idx === -1) return { name: title, when: "" };
|
function nameAndWhen(s: { title: string; when_to_use?: string }): { name: string; when: string } {
|
||||||
return { name: title.slice(0, idx), when: title.slice(idx + 3) };
|
const when = s.when_to_use || "";
|
||||||
|
if (when && s.title.endsWith(` — ${when}`)) {
|
||||||
|
return { name: s.title.slice(0, -(when.length + 3)), when };
|
||||||
|
}
|
||||||
|
if (when) return { name: s.title, when };
|
||||||
|
const idx = s.title.indexOf(" — ");
|
||||||
|
if (idx === -1) return { name: s.title, when: "" };
|
||||||
|
return { name: s.title.slice(0, idx), when: s.title.slice(idx + 3) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function languageOf(tags: string[]): string {
|
function languageOf(tags: string[]): string {
|
||||||
@@ -313,7 +320,7 @@ function driftTitle(s: SnippetListItem): string {
|
|||||||
<div v-for="(g, i) in duplicateGroups" :key="i" class="dup-group">
|
<div v-for="(g, i) in duplicateGroups" :key="i" class="dup-group">
|
||||||
<div class="dup-members">
|
<div class="dup-members">
|
||||||
<span v-for="s in g.snippets" :key="s.id" class="dup-member">
|
<span v-for="s in g.snippets" :key="s.id" class="dup-member">
|
||||||
{{ splitTitle(s.title).name }}
|
{{ nameAndWhen(s).name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="dup-score">{{ Math.round(g.top_score * 100) }}% alike</span>
|
<span class="dup-score">{{ Math.round(g.top_score * 100) }}% alike</span>
|
||||||
@@ -413,11 +420,11 @@ function driftTitle(s: SnippetListItem): string {
|
|||||||
:class="{ on: selectedIds.has(s.id) }"
|
:class="{ on: selectedIds.has(s.id) }"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
<span class="snippet-name">{{ splitTitle(s.title).name }}</span>
|
<span class="snippet-name">{{ nameAndWhen(s).name }}</span>
|
||||||
<span v-if="languageOf(s.tags)" class="lang-pill">{{ languageOf(s.tags) }}</span>
|
<span v-if="languageOf(s.tags)" class="lang-pill">{{ languageOf(s.tags) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="splitTitle(s.title).when" class="snippet-when">
|
<p v-if="nameAndWhen(s).when" class="snippet-when">
|
||||||
{{ splitTitle(s.title).when }}
|
{{ nameAndWhen(s).when }}
|
||||||
</p>
|
</p>
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<span class="meta-date">Updated {{ new Date(s.updated_at).toLocaleDateString() }}</span>
|
<span class="meta-date">Updated {{ new Date(s.updated_at).toLocaleDateString() }}</span>
|
||||||
@@ -458,7 +465,7 @@ function driftTitle(s: SnippetListItem): string {
|
|||||||
:class="{ chosen: canonicalId === s.id }"
|
:class="{ chosen: canonicalId === s.id }"
|
||||||
>
|
>
|
||||||
<input type="radio" name="canonical" :value="s.id" v-model="canonicalId" />
|
<input type="radio" name="canonical" :value="s.id" v-model="canonicalId" />
|
||||||
<span class="merge-choice-name">{{ splitTitle(s.title).name }}</span>
|
<span class="merge-choice-name">{{ nameAndWhen(s).name }}</span>
|
||||||
<span class="merge-choice-tag">{{ canonicalId === s.id ? "keep" : "fold in" }}</span>
|
<span class="merge-choice-tag">{{ canonicalId === s.id ? "keep" : "fold in" }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"name": "scribe",
|
||||||
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
|
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
|
||||||
"version": "2026.09.21.0503",
|
"version": "2026.09.23.2008",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Bryan Van Deusen"
|
"name": "Bryan Van Deusen"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ event_flat=$(printf '%s' "$event" | scribe_json_flat)
|
|||||||
prompt=$(scribe_json_pick "$event_flat" '.prompt')
|
prompt=$(scribe_json_pick "$event_flat" '.prompt')
|
||||||
session_id=$(scribe_json_pick "$event_flat" '.session_id')
|
session_id=$(scribe_json_pick "$event_flat" '.session_id')
|
||||||
event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
|
event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
|
||||||
|
transcript=$(scribe_json_pick "$event_flat" '.transcript_path')
|
||||||
|
|
||||||
# Nothing to retrieve against.
|
# Nothing to retrieve against.
|
||||||
[ -n "$prompt" ] || exit 0
|
[ -n "$prompt" ] || exit 0
|
||||||
@@ -79,6 +80,16 @@ q=$(printf '%s' "$prompt" | head -c 2000)
|
|||||||
q_enc=$(printf '%s' "$q" | scribe_urlenc) || exit 0
|
q_enc=$(printf '%s' "$q" | scribe_urlenc) || exit 0
|
||||||
[ -n "$q_enc" ] || exit 0
|
[ -n "$q_enc" ] || exit 0
|
||||||
|
|
||||||
|
# What the conversation is about, for a prompt too short to say (#4364). Sent
|
||||||
|
# beside `q`, never folded into it: the server decides whether the prompt is
|
||||||
|
# thin enough to need it, for the notes and rule arms alike. Capped at
|
||||||
|
# 600 chars here as well as there, so the URL stays small whatever the reply.
|
||||||
|
ctx_q=""
|
||||||
|
ctx=$(scribe_recent_context "$transcript")
|
||||||
|
if [ -n "$ctx" ]; then
|
||||||
|
ctx_enc=$(printf '%s' "$ctx" | scribe_urlenc) && [ -n "$ctx_enc" ] && ctx_q="&ctx=${ctx_enc}"
|
||||||
|
fi
|
||||||
|
|
||||||
# Scope to this directory's project — a `.scribe` marker, else the git remote.
|
# Scope to this directory's project — a `.scribe` marker, else the git remote.
|
||||||
repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
||||||
scope=$(scribe_scope_query "$repo_dir")
|
scope=$(scribe_scope_query "$repo_dir")
|
||||||
@@ -120,7 +131,7 @@ fi
|
|||||||
|
|
||||||
body=$(curl -fsS --max-time 5 \
|
body=$(curl -fsS --max-time 5 \
|
||||||
-H "Authorization: Bearer ${token}" \
|
-H "Authorization: Bearer ${token}" \
|
||||||
"${url%/}/api/plugin/retrieve?q=${q_enc}${repo_q}${exclude_q}" 2>/dev/null) || exit 0
|
"${url%/}/api/plugin/retrieve?q=${q_enc}${ctx_q}${repo_q}${exclude_q}" 2>/dev/null) || exit 0
|
||||||
[ -n "$body" ] || exit 0
|
[ -n "$body" ] || exit 0
|
||||||
|
|
||||||
body_flat=$(printf '%s' "$body" | scribe_json_flat)
|
body_flat=$(printf '%s' "$body" | scribe_json_flat)
|
||||||
|
|||||||
@@ -127,6 +127,36 @@ scribe_json_flat_lines() {
|
|||||||
awk -v mode=lines -f "$SCRIBE_HOOK_DIR/scribe_json.awk" 2>/dev/null || true
|
awk -v mode=lines -f "$SCRIBE_HOOK_DIR/scribe_json.awk" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# $1 transcript path → the tail of the last assistant REPLY text, decoded, on
|
||||||
|
# one line; "" when there is none (#4364). The auto-inject query pairs it with
|
||||||
|
# a short prompt, so "yes do that" still says what it is about.
|
||||||
|
#
|
||||||
|
# Cheap by construction, because it runs on every prompt: the last 512 KB only,
|
||||||
|
# and grep narrows to assistant records carrying a text block BEFORE anything is
|
||||||
|
# parsed — a transcript is mostly tool results, and parsing those in awk is the
|
||||||
|
# multi-second cost scribe_report_check.sh already measured. Fixed strings with
|
||||||
|
# UNESCAPED quotes can only match at a record's own top level (see that hook).
|
||||||
|
# A sidechain is a subagent talking, not this session, so it is skipped.
|
||||||
|
scribe_recent_context() {
|
||||||
|
[ -n "${1:-}" ] && [ -f "$1" ] || return 0
|
||||||
|
tail -c 524288 "$1" 2>/dev/null \
|
||||||
|
| grep -F '"role":"assistant"' 2>/dev/null \
|
||||||
|
| grep -F '"type":"text"' 2>/dev/null \
|
||||||
|
| tail -n 4 \
|
||||||
|
| scribe_json_flat_lines \
|
||||||
|
| awk -F'\t' '
|
||||||
|
$2 == ".isSidechain" { side[$1] = $3; next }
|
||||||
|
$2 ~ /^\.message\.content\[[0-9]+\]\.text$/ { txt[$1] = txt[$1] " " $3; if ($1 > last) last = $1 }
|
||||||
|
END { for (i = last; i >= 0; i--) if ((i in txt) && side[i] != "true") { print txt[i]; exit } }
|
||||||
|
' 2>/dev/null \
|
||||||
|
| scribe_json_unescape \
|
||||||
|
| tr '\n\t' ' ' \
|
||||||
|
| awk '{ sub(/[ ]+$/, ""); n = length($0); if (n) print (n > 600 ? substr($0, n - 599) : $0) }'
|
||||||
|
# A transcript with no reply yet is an answer ("nothing"), not a failure —
|
||||||
|
# under pipefail the grep that matched nothing would otherwise be the status.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# $1 flat text, $2 exact path → the decoded scalar, or "" if absent.
|
# $1 flat text, $2 exact path → the decoded scalar, or "" if absent.
|
||||||
#
|
#
|
||||||
# `null` READS AS ABSENT, matching the `// empty` every call site used to carry.
|
# `null` READS AS ABSENT, matching the `// empty` every call site used to carry.
|
||||||
|
|||||||
@@ -189,11 +189,35 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
|||||||
scope=$(scribe_scope_query "$repo_dir")
|
scope=$(scribe_scope_query "$repo_dir")
|
||||||
q=""
|
q=""
|
||||||
[ -n "$scope" ] && q="?${scope}"
|
[ -n "$scope" ] && q="?${scope}"
|
||||||
body=$(curl -fsS --max-time 8 \
|
# ONE FETCH, NAMED WHEN IT FAILS (#4366). This used to be `curl -f … ||
|
||||||
-H "Authorization: Bearer ${token}" \
|
# body=""`, which folded a timeout, an HTTP error and a refused key into one
|
||||||
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
|
# sentence — so a session that started blind could not say why, and neither
|
||||||
|
# could the operator afterwards. curl's write-out still prints on a failed
|
||||||
|
# transfer (as `000`), so the status and the elapsed time come back either way.
|
||||||
|
fetch_context() {
|
||||||
|
local resp meta
|
||||||
|
resp=$(curl -sS --max-time "$1" -w '\n%{http_code} %{time_total}' \
|
||||||
|
-H "Authorization: Bearer ${token}" \
|
||||||
|
"${url%/}/api/plugin/context${q}" 2>/dev/null)
|
||||||
|
ctx_rc=$?
|
||||||
|
meta=${resp##*$'\n'}
|
||||||
|
body=${resp%$'\n'*}
|
||||||
|
[ "$body" = "$resp" ] && body=""
|
||||||
|
ctx_code=${meta%% *}
|
||||||
|
ctx_took=${meta#* }
|
||||||
|
}
|
||||||
|
# Deadlines: 8s is the long-standing first try, sized for a cold instance.
|
||||||
|
# ONE retry at 6s, and only for failures a second try can change — a
|
||||||
|
# timeout, a dropped connection, a 5xx. A 4xx is the key or the scope and
|
||||||
|
# will say the same thing twice. Worst case is ~14s of startup, against a
|
||||||
|
# whole session run without its project.
|
||||||
|
fetch_context 8
|
||||||
|
case "$ctx_rc:$ctx_code" in
|
||||||
|
0:2*|0:4*) ;;
|
||||||
|
*) fetch_context 6 ;;
|
||||||
|
esac
|
||||||
body_flat=""
|
body_flat=""
|
||||||
if [ -n "$body" ]; then
|
if [ "$ctx_rc" = 0 ] && [ "${ctx_code#2}" != "$ctx_code" ] && [ -n "$body" ]; then
|
||||||
body_flat=$(printf '%s' "$body" | scribe_json_flat)
|
body_flat=$(printf '%s' "$body" | scribe_json_flat)
|
||||||
dyn=$(scribe_json_pick "$body_flat" '.context')
|
dyn=$(scribe_json_pick "$body_flat" '.context')
|
||||||
fi
|
fi
|
||||||
@@ -201,7 +225,28 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
|||||||
# (milestone 394). Nothing is preloaded, so there is no set whose
|
# (milestone 394). Nothing is preloaded, so there is no set whose
|
||||||
# drift a later write could be told about — a rule is retrieved at
|
# drift a later write could be told about — a rule is retrieved at
|
||||||
# the moment it applies, which cannot be stale.
|
# the moment it applies, which cannot be stale.
|
||||||
[ -z "$dyn" ] && status="> ⚠️ Scribe: live project context could not be loaded this session (instance unreachable or request failed). The using-scribe skill still applies — ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\` as needed."
|
if [ -z "$dyn" ]; then
|
||||||
|
if [ "$ctx_rc" = 28 ]; then
|
||||||
|
why="the instance did not answer in time (8s, then ${ctx_took}s on a retry)"
|
||||||
|
elif [ "$ctx_rc" != 0 ]; then
|
||||||
|
why="the instance could not be reached (curl exit ${ctx_rc}, after a retry)"
|
||||||
|
elif [ "$ctx_code" = 401 ] || [ "$ctx_code" = 403 ]; then
|
||||||
|
why="the API key was refused (HTTP ${ctx_code})"
|
||||||
|
elif [ "${ctx_code#2}" = "$ctx_code" ]; then
|
||||||
|
why="the instance answered HTTP ${ctx_code}"
|
||||||
|
else
|
||||||
|
why="the instance answered but sent no context"
|
||||||
|
fi
|
||||||
|
# The first move, stated as one. "As needed" read as optional, and a
|
||||||
|
# session that skips it starts with no recent milestones or open tasks —
|
||||||
|
# so it cannot know what prior work exists to look for (#4366).
|
||||||
|
if [ -n "$marker_id" ] && [ -z "$marker_why" ]; then
|
||||||
|
first="Start by calling \`enter_project(${marker_id})\`"
|
||||||
|
else
|
||||||
|
first="Start by finding this repo's project with \`list_projects()\` and calling \`enter_project(<id>)\`"
|
||||||
|
fi
|
||||||
|
status="> ⚠️ Scribe: live project context was not loaded this session — ${why}. The tools may still answer. ${first} before any other work: it loads the recent milestones and open tasks this session would otherwise begin without, and prior work you cannot see is work you will redo. The using-scribe skill still applies."
|
||||||
|
fi
|
||||||
elif [ -n "$url" ] && [ -z "$token" ]; then
|
elif [ -n "$url" ] && [ -z "$token" ]; then
|
||||||
status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`."
|
status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`."
|
||||||
elif [ -z "$url" ] && [ -z "$token" ]; then
|
elif [ -z "$url" ] && [ -z "$token" ]; then
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ async def create_lesson(
|
|||||||
dup = await dedup_svc.find_duplicate_note(
|
dup = await dedup_svc.find_duplicate_note(
|
||||||
uid, title, body, project_id=project_id or None,
|
uid, title, body, project_id=project_id or None,
|
||||||
is_task=False, note_type=lessons_svc.LESSON_NOTE_TYPE,
|
is_task=False, note_type=lessons_svc.LESSON_NOTE_TYPE,
|
||||||
|
data=lessons_svc.compose_data(what, when_to_apply),
|
||||||
)
|
)
|
||||||
if dup is not None:
|
if dup is not None:
|
||||||
return dedup_svc.duplicate_response(dup, "lesson")
|
return dedup_svc.duplicate_response(dup, "lesson")
|
||||||
|
|||||||
@@ -176,7 +176,9 @@ async def create_snippet(
|
|||||||
raise ValueError("create_snippet requires a non-empty name and code")
|
raise ValueError("create_snippet requires a non-empty name and code")
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
|
|
||||||
title = snippets_svc.compose_title(name, when_to_use)
|
# The NAME is the title (milestone 427); the trigger rides in `data` and
|
||||||
|
# joins the title only in the embedded document.
|
||||||
|
title = name.strip()
|
||||||
body = snippets_svc.compose_body(
|
body = snippets_svc.compose_body(
|
||||||
code=code, language=language, signature=signature,
|
code=code, language=language, signature=signature,
|
||||||
when_to_use=when_to_use, repo=repo, path=path, symbol=symbol,
|
when_to_use=when_to_use, repo=repo, path=path, symbol=symbol,
|
||||||
@@ -190,6 +192,7 @@ async def create_snippet(
|
|||||||
# location and code before it compares prose (#2518).
|
# location and code before it compares prose (#2518).
|
||||||
code=code,
|
code=code,
|
||||||
locations=snippets_svc.resolve_locations(repo, path, symbol, locations),
|
locations=snippets_svc.resolve_locations(repo, path, symbol, locations),
|
||||||
|
data=snippets_svc.compose_data(name=name, when_to_use=when_to_use),
|
||||||
)
|
)
|
||||||
if dup is not None:
|
if dup is not None:
|
||||||
return dedup_svc.duplicate_response(dup, "snippet")
|
return dedup_svc.duplicate_response(dup, "snippet")
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ async def create_lesson_route():
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
is_task=False,
|
is_task=False,
|
||||||
note_type=lessons_svc.LESSON_NOTE_TYPE,
|
note_type=lessons_svc.LESSON_NOTE_TYPE,
|
||||||
|
data=lessons_svc.compose_data(what, when_to_apply),
|
||||||
)
|
)
|
||||||
if dup is not None:
|
if dup is not None:
|
||||||
return jsonify(dedup_svc.duplicate_response(dup, "lesson")), 409
|
return jsonify(dedup_svc.duplicate_response(dup, "lesson")), 409
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ async def autoinject_retrieve():
|
|||||||
project_id (opt) — explicit project scope override (ad-hoc/testing).
|
project_id (opt) — explicit project scope override (ad-hoc/testing).
|
||||||
exclude_ids (opt) — comma-separated note ids already injected this
|
exclude_ids (opt) — comma-separated note ids already injected this
|
||||||
session; skipped so each note injects at most once.
|
session; skipped so each note injects at most once.
|
||||||
|
ctx (opt) — the tail of the last assistant reply (#4364). The
|
||||||
|
notes AND rule arms append it to a short prompt,
|
||||||
|
so a follow-up like "yes do that" still names
|
||||||
|
what it is about — and a rule or lesson arrives
|
||||||
|
while the work is under way, before the operator
|
||||||
|
has to call it out.
|
||||||
exclude_rule_ids — comma-separated rule ids already surfaced this
|
exclude_rule_ids — comma-separated rule ids already surfaced this
|
||||||
(opt) session. SHARED with /prior-art and /tool-rules on
|
(opt) session. SHARED with /prior-art and /tool-rules on
|
||||||
purpose: one session keeps ONE rule ledger, so a
|
purpose: one session keeps ONE rule ledger, so a
|
||||||
@@ -125,11 +131,13 @@ async def autoinject_retrieve():
|
|||||||
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
|
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
|
||||||
held_rule_ids = _int_list(request.args.get("held_rule_ids"))
|
held_rule_ids = _int_list(request.args.get("held_rule_ids"))
|
||||||
|
|
||||||
|
ctx = request.args.get("ctx") or ""
|
||||||
rules = await plugin_ctx_svc.build_prompt_rule_hint(
|
rules = await plugin_ctx_svc.build_prompt_rule_hint(
|
||||||
g.user.id, q, project_id=project_id, exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids
|
g.user.id, q, project_id=project_id, exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
|
||||||
|
context=ctx,
|
||||||
)
|
)
|
||||||
result = await plugin_ctx_svc.build_autoinject_hint(
|
result = await plugin_ctx_svc.build_autoinject_hint(
|
||||||
g.user.id, q, project_id=project_id, exclude_ids=exclude_ids
|
g.user.id, q, project_id=project_id, exclude_ids=exclude_ids, context=ctx,
|
||||||
)
|
)
|
||||||
blocks = [b for b in (rules["context"], result["context"]) if b]
|
blocks = [b for b in (rules["context"], result["context"]) if b]
|
||||||
result["context"] = "\n\n".join(blocks)
|
result["context"] = "\n\n".join(blocks)
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ async def create_snippet_route():
|
|||||||
if not data.get("force"):
|
if not data.get("force"):
|
||||||
dup = await dedup_svc.find_duplicate_note(
|
dup = await dedup_svc.find_duplicate_note(
|
||||||
uid,
|
uid,
|
||||||
snippets_svc.compose_title(name, data.get("when_to_use", "")),
|
name.strip(),
|
||||||
snippets_svc.compose_body(
|
snippets_svc.compose_body(
|
||||||
code=data.get("code", ""),
|
code=data.get("code", ""),
|
||||||
language=data.get("language", ""),
|
language=data.get("language", ""),
|
||||||
@@ -119,6 +119,9 @@ async def create_snippet_route():
|
|||||||
data.get("repo", ""), data.get("path", ""), data.get("symbol", ""),
|
data.get("repo", ""), data.get("path", ""), data.get("symbol", ""),
|
||||||
data.get("locations"),
|
data.get("locations"),
|
||||||
),
|
),
|
||||||
|
data=snippets_svc.compose_data(
|
||||||
|
name=name, when_to_use=data.get("when_to_use", ""),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if dup is not None:
|
if dup is not None:
|
||||||
return jsonify(dedup_svc.duplicate_response(dup, "snippet")), 409
|
return jsonify(dedup_svc.duplicate_response(dup, "snippet")), 409
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ async def find_duplicate_note(
|
|||||||
note_type: str = "note",
|
note_type: str = "note",
|
||||||
code: str = "",
|
code: str = "",
|
||||||
locations: list[dict] | None = None,
|
locations: list[dict] | None = None,
|
||||||
|
data: dict | None = None,
|
||||||
) -> DuplicateMatch | None:
|
) -> DuplicateMatch | None:
|
||||||
"""Best near-duplicate of (title, body) within the same owner + project +
|
"""Best near-duplicate of (title, body) within the same owner + project +
|
||||||
kind, or None. Title match first (cheap, exact), then — for snippets — the
|
kind, or None. Title match first (cheap, exact), then — for snippets — the
|
||||||
@@ -258,6 +259,11 @@ async def find_duplicate_note(
|
|||||||
`code` and `locations` are the snippet's structured fields. They are ignored
|
`code` and `locations` are the snippet's structured fields. They are ignored
|
||||||
for every other kind, and passing them is what lets the gate compare
|
for every other kind, and passing them is what lets the gate compare
|
||||||
ARTEFACTS rather than descriptions of artefacts (#2518).
|
ARTEFACTS rather than descriptions of artefacts (#2518).
|
||||||
|
|
||||||
|
`data` is the candidate's structured mirror. For a snippet or lesson it
|
||||||
|
carries the trigger, which the TITLE no longer does (milestone 427): the
|
||||||
|
title check compares names, and the semantic check rebuilds the embedded
|
||||||
|
document from `data`.
|
||||||
"""
|
"""
|
||||||
norm = " ".join((title or "").split()).lower()
|
norm = " ".join((title or "").split()).lower()
|
||||||
|
|
||||||
@@ -309,7 +315,11 @@ async def find_duplicate_note(
|
|||||||
# section. Capped so one pathological paste can't turn a save into
|
# section. Capped so one pathological paste can't turn a save into
|
||||||
# dozens of searches — a duplicate past the cap is the duplicate
|
# dozens of searches — a duplicate past the cap is the duplicate
|
||||||
# report's job, not the gate's.
|
# report's job, not the gate's.
|
||||||
for query in embeddings_svc.chunk_document(title, body)[:_GATE_MAX_CHUNKS]:
|
# The EMBEDDED title (milestone 427): a snippet or lesson is stored
|
||||||
|
# under its name and embedded under `name — trigger`, so the query
|
||||||
|
# document is built the way the corpus was, from `data`.
|
||||||
|
doc_title = embeddings_svc.document_title(title, note_type, data, body)
|
||||||
|
for query in embeddings_svc.chunk_document(doc_title, body)[:_GATE_MAX_CHUNKS]:
|
||||||
# Scope the semantic check the same way as the title check: a record
|
# Scope the semantic check the same way as the title check: a record
|
||||||
# in project P compares only to P; a project-less (orphan) record
|
# in project P compares only to P; a project-less (orphan) record
|
||||||
# compares only to other orphans (orphan_only), NOT across every
|
# compares only to other orphans (orphan_only), NOT across every
|
||||||
|
|||||||
@@ -214,10 +214,12 @@ TRIGGER_SEP = " — "
|
|||||||
def trigger_title(subject: str | None, trigger: str | None) -> str:
|
def trigger_title(subject: str | None, trigger: str | None) -> str:
|
||||||
"""`{subject} — {trigger}` — the title half of a situation-keyed document.
|
"""`{subject} — {trigger}` — the title half of a situation-keyed document.
|
||||||
|
|
||||||
ONE definition, because this join had three. `rule_document` built it for
|
ONE definition, because this join had three — rules, snippets, and a
|
||||||
rules, `snippets.compose_title` for snippets, and milestone 385 needed a
|
fourth for lessons (milestone 385) — the shape #3207 records, where a fix
|
||||||
fourth for lessons — the shape #3207 records, where a fix or an improvement
|
or an improvement then has to be found in N places by someone who does not
|
||||||
then has to be found in N places by someone who does not know N.
|
know N. Since milestone 427 it builds EMBEDDED titles only: `rule_document`
|
||||||
|
for rules and `document_title` for snippets and lessons. No stored title
|
||||||
|
carries it.
|
||||||
|
|
||||||
WHY THE JOIN MATTERS AT ALL, measured in note #2485: the snippet was the
|
WHY THE JOIN MATTERS AT ALL, measured in note #2485: the snippet was the
|
||||||
only sharp record in the corpus — a 0.153 top-to-second gap against
|
only sharp record in the corpus — a 0.153 top-to-second gap against
|
||||||
@@ -265,6 +267,51 @@ def untrigger_title(title: str | None, trigger: str | None) -> str:
|
|||||||
return title
|
return title
|
||||||
|
|
||||||
|
|
||||||
|
# The `data` key each trigger-keyed note kind mirrors its trigger under. Rules
|
||||||
|
# are not here: they keep the trigger in a column and `rule_document` builds
|
||||||
|
# their document from it.
|
||||||
|
_TRIGGER_DATA_KEYS = {"snippet": "when_to_use", "lesson": "when_to_apply"}
|
||||||
|
|
||||||
|
|
||||||
|
def document_title(
|
||||||
|
title: str | None, note_type: str | None, data: dict | None = None,
|
||||||
|
body: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""The title a note is EMBEDDED under — its stored title, plus its trigger.
|
||||||
|
|
||||||
|
Milestone 427. A snippet's or lesson's STORED title is its subject alone;
|
||||||
|
the trigger lives in `data` (decision #4157). It still has to reach the
|
||||||
|
vector — the `subject — trigger` join is what makes these kinds rank on
|
||||||
|
the situation they apply to (#2485) — so it is joined HERE, at embed time,
|
||||||
|
rather than being carried in a title every listing then has to show.
|
||||||
|
|
||||||
|
IDEMPOTENT, and that is what makes the migration safe: a title that is
|
||||||
|
already composed (a row not yet migrated, an old backup restored) is
|
||||||
|
untriggered first, so it comes out the same and never doubled. The text is
|
||||||
|
byte-identical to what these kinds were embedded as before, so no vector
|
||||||
|
moves and the floors tuned against them stay calibrated.
|
||||||
|
|
||||||
|
`body` is the fallback when the mirror is missing, read by the kind's own
|
||||||
|
parser — the same degrade-to-the-body each kind's reader already has.
|
||||||
|
Every other kind, and a record with no trigger, keeps its title as-is.
|
||||||
|
"""
|
||||||
|
key = _TRIGGER_DATA_KEYS.get(note_type or "")
|
||||||
|
if key is None:
|
||||||
|
return title
|
||||||
|
trigger = ((data or {}).get(key) or "").strip() if isinstance(data, dict) else ""
|
||||||
|
if not trigger and body:
|
||||||
|
from types import SimpleNamespace
|
||||||
|
if note_type == "lesson":
|
||||||
|
from scribe.services.lessons import lesson_trigger
|
||||||
|
trigger = lesson_trigger(SimpleNamespace(data=None, body=body))
|
||||||
|
else:
|
||||||
|
from scribe.services.snippets import parse_snippet_fields
|
||||||
|
trigger = parse_snippet_fields(title or "", body).get("when_to_use", "")
|
||||||
|
if not trigger:
|
||||||
|
return title
|
||||||
|
return trigger_title(untrigger_title(title, trigger), trigger)
|
||||||
|
|
||||||
|
|
||||||
# --- chunking (#280): the document shape ------------------------------------
|
# --- chunking (#280): the document shape ------------------------------------
|
||||||
#
|
#
|
||||||
# bge-small reads at most 512 tokens and fastembed silently truncates the rest,
|
# bge-small reads at most 512 tokens and fastembed silently truncates the rest,
|
||||||
@@ -1081,10 +1128,14 @@ async def backfill_note_embeddings() -> None:
|
|||||||
)
|
)
|
||||||
success = 0
|
success = 0
|
||||||
for note_id in notes_to_embed:
|
for note_id in notes_to_embed:
|
||||||
row = await _current_row((Note.user_id, Note.title, Note.body), Note.id, note_id)
|
row = await _current_row(
|
||||||
|
(Note.user_id, Note.title, Note.body, Note.note_type, Note.data), Note.id, note_id,
|
||||||
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
continue # deleted between the scan and here
|
continue # deleted between the scan and here
|
||||||
user_id, title, body = row
|
user_id, title, body, note_type, data = row
|
||||||
|
# The EMBEDDED title, as the write path builds it (milestone 427).
|
||||||
|
title = document_title(title, note_type, data, body)
|
||||||
if not chunk_document(title, body):
|
if not chunk_document(title, body):
|
||||||
continue
|
continue
|
||||||
await upsert_note_embedding(note_id, user_id, title, body)
|
await upsert_note_embedding(note_id, user_id, title, body)
|
||||||
|
|||||||
@@ -267,6 +267,11 @@ def _note_to_item(note: Note, chunks: dict[int, dict] | None = None) -> dict:
|
|||||||
trigger = (note.data or {}).get("when_to_apply") if note.data else None
|
trigger = (note.data or {}).get("when_to_apply") if note.data else None
|
||||||
if trigger:
|
if trigger:
|
||||||
item["when_to_apply"] = trigger
|
item["when_to_apply"] = trigger
|
||||||
|
# A snippet's, for the same reason — and since milestone 427 the title no
|
||||||
|
# longer carries it, so without this a list shows names with no situation.
|
||||||
|
usage = (note.data or {}).get("when_to_use") if note.data else None
|
||||||
|
if usage:
|
||||||
|
item["when_to_use"] = usage
|
||||||
|
|
||||||
verdict = (note.data or {}).get("verification") if note.data else None
|
verdict = (note.data or {}).get("verification") if note.data else None
|
||||||
if verdict and verdict.get("status"):
|
if verdict and verdict.get("status"):
|
||||||
|
|||||||
@@ -26,20 +26,23 @@ while staying a note in every other respect.
|
|||||||
WHERE THE TRIGGER LIVES (decision #4157, milestone 385 step 1)
|
WHERE THE TRIGGER LIVES (decision #4157, milestone 385 step 1)
|
||||||
|
|
||||||
In ``notes.data`` under ``when_to_apply``, written through a named parameter and
|
In ``notes.data`` under ``when_to_apply``, written through a named parameter and
|
||||||
mirrored into the title and the head of the body — the shape snippets already
|
mirrored into the head of the body — the shape snippets already use for
|
||||||
use for ``when_to_use``. Not a column on ``notes``.
|
``when_to_use``. Not a column on ``notes``. Since milestone 427 it is NOT in the
|
||||||
|
stored title: the title is the lesson's subject, and the trigger joins it only
|
||||||
|
in the embedded document (``embeddings.document_title``).
|
||||||
|
|
||||||
That decision was measured rather than assumed. The whole snippet corpus —
|
That decision was measured rather than assumed. The whole snippet corpus —
|
||||||
164 of 164 — carries a ``when_to_use`` with **no guard anywhere**, which refutes
|
164 of 164 — carries a ``when_to_use`` with **no guard anywhere**, which refutes
|
||||||
the premise that an unenforced field gets skipped. What it does NOT show is that
|
the premise that an unenforced field gets skipped. What it does NOT show is that
|
||||||
an agent types a title convention correctly: ``compose_title`` builds the title
|
an agent types a title convention correctly: the service composed the title
|
||||||
from the parameter, so what is at 100% is a named structured field. A column
|
from the parameter, so what is at 100% is a named structured field. A column
|
||||||
would have bought enforceability at the price of deciding, for every note kind
|
would have bought enforceability at the price of deciding, for every note kind
|
||||||
at once, a question nothing had measured.
|
at once, a question nothing had measured.
|
||||||
|
|
||||||
The mirror is what makes the vector sharp, and it is why nothing re-embeds:
|
The trigger in the document is what makes the vector sharp. It reaches it
|
||||||
``chunk_document`` is untouched, so ``CHUNKER_VERSION`` does not move. The
|
twice — in the embedded title, joined at embed time, and in the body's first
|
||||||
trigger reaches the document by being in the text, exactly as a snippet's is.
|
line — which is the text these kinds were always embedded as, so nothing
|
||||||
|
re-embeds and ``CHUNKER_VERSION`` does not move.
|
||||||
|
|
||||||
WHAT A LESSON INHERITS, AND THE CELLS LEFT EMPTY ON PURPOSE (#3163)
|
WHAT A LESSON INHERITS, AND THE CELLS LEFT EMPTY ON PURPOSE (#3163)
|
||||||
|
|
||||||
@@ -207,36 +210,17 @@ def sole_source(sources: list[int] | None) -> int | None:
|
|||||||
return ids[0] if len(ids) == 1 else None
|
return ids[0] if len(ids) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
def compose_title(what: str, when_to_apply: str = "") -> str:
|
|
||||||
"""`{what} — {when it applies}`, the half of the document that ranks.
|
|
||||||
|
|
||||||
Built HERE rather than asked of the caller, and that distinction is the
|
|
||||||
whole evidence base for this design: the snippet corpus is at 100% on its
|
|
||||||
trigger because a service composes the title from a named parameter, not
|
|
||||||
because agents type separators reliably. A caller made to spell the
|
|
||||||
convention is the option milestone 385 step 1 rejected.
|
|
||||||
|
|
||||||
The join is `embeddings.trigger_title` — shared with rules and snippets, so
|
|
||||||
the three kinds that rank on a trigger cannot drift apart in how they say
|
|
||||||
so.
|
|
||||||
"""
|
|
||||||
from scribe.services.embeddings import trigger_title
|
|
||||||
|
|
||||||
return trigger_title(what, when_to_apply)
|
|
||||||
|
|
||||||
|
|
||||||
def compose_body(
|
def compose_body(
|
||||||
insight: str, when_to_apply: str = "", learned_from: list[int] | None = None,
|
insight: str, when_to_apply: str = "", learned_from: list[int] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""The lesson body — the trigger line first, the insight after.
|
"""The lesson body — the trigger line first, the insight after.
|
||||||
|
|
||||||
The mirror of `compose_title` on the other half of the document, and the
|
The trigger's home in the text of the document, and half of what makes a
|
||||||
reason the pair is what makes a lesson findable: `chunk_document` joins
|
lesson findable: `chunk_document` joins `{embedded title}\\n{body}`, and
|
||||||
them as `{title}\\n{body}`, so a lesson composed here states WHEN IT
|
the embedded title is `what — when it applies` (`embeddings.document_title`,
|
||||||
APPLIES in the title and again in the first line of the body. That is the
|
milestone 427), so the document states WHEN IT APPLIES in the title and
|
||||||
twice-in-a-short-document shape note #2485 measured as the only sharp one
|
again in the first line of the body. That is the twice-in-a-short-document
|
||||||
in the corpus, reached the way a snippet reaches it — by being in the text
|
shape note #2485 measured as the only sharp one in the corpus.
|
||||||
— rather than by a second document builder at embed time.
|
|
||||||
|
|
||||||
`**When to apply:**` rather than plain text: the body is the READABLE
|
`**When to apply:**` rather than plain text: the body is the READABLE
|
||||||
form, `data` is the queryable mirror, and `_BODY_TRIGGER_RE` reads this
|
form, `data` is the queryable mirror, and `_BODY_TRIGGER_RE` reads this
|
||||||
@@ -275,23 +259,23 @@ def lesson_document(
|
|||||||
what: str, when_to_apply: str = "", insight: str = "",
|
what: str, when_to_apply: str = "", insight: str = "",
|
||||||
learned_from: list[int] | None = None,
|
learned_from: list[int] | None = None,
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""The (title, body) a lesson is STORED — and therefore embedded — as.
|
"""The (title, body) a lesson is STORED as.
|
||||||
|
|
||||||
One call so the two halves cannot be composed apart. A lesson whose title
|
One call so the two halves cannot be composed apart. The body's first line
|
||||||
carried the trigger and whose body did not would embed as an ordinary
|
carries the trigger; a lesson without it (and without the `data` mirror)
|
||||||
note wearing a label, and nothing would report it: the record would look
|
would embed as an ordinary note wearing a label, and nothing would report
|
||||||
right in every listing and simply never be retrieved at the moment it
|
it: the record would look right in every listing and simply never be
|
||||||
applies.
|
retrieved at the moment it applies.
|
||||||
|
|
||||||
Deliberately returns what is STORED, not a separate embed-time shape.
|
The title is the SUBJECT alone (milestone 427). It used to carry the
|
||||||
Rules need `rule_document` because a rule keeps its trigger in a column
|
trigger too, so the stored record was itself the sharp document — and every
|
||||||
and its title is a plain name, so the sharp document has to be synthesised
|
listing, menu and search row then showed a title that ran to kilobytes.
|
||||||
for the ranker and exists nowhere else. A lesson follows the snippet
|
The trigger now joins the title at embed time (`embeddings.document_title`,
|
||||||
instead — the stored record IS the sharp document — which is why nothing
|
reading `data`), producing the same text as before, so nothing re-embeds
|
||||||
re-embeds and `CHUNKER_VERSION` does not move.
|
and `CHUNKER_VERSION` does not move.
|
||||||
"""
|
"""
|
||||||
return (
|
return (
|
||||||
compose_title(what, when_to_apply),
|
(what or "").strip(),
|
||||||
compose_body(insight, when_to_apply, learned_from),
|
compose_body(insight, when_to_apply, learned_from),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -115,11 +115,17 @@ def embed_note(note) -> None:
|
|||||||
try:
|
try:
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from scribe.services.embeddings import upsert_note_embedding
|
from scribe.services.embeddings import document_title, upsert_note_embedding
|
||||||
# Chunking and the empty-record gate live inside upsert_note_embedding —
|
# Chunking and the empty-record gate live inside upsert_note_embedding —
|
||||||
# one path for every writer (#280).
|
# one path for every writer (#280). The title is the EMBEDDED one: a
|
||||||
|
# snippet's or lesson's trigger joins its name here, not in the stored
|
||||||
|
# title (milestone 427).
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
upsert_note_embedding(note.id, note.user_id, note.title, note.body)
|
upsert_note_embedding(
|
||||||
|
note.id, note.user_id,
|
||||||
|
document_title(note.title, note.note_type, note.data, note.body),
|
||||||
|
note.body,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass # no running loop — a sync caller, not a failure
|
pass # no running loop — a sync caller, not a failure
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ from scribe.services import projects as projects_svc
|
|||||||
from scribe.services import shape_ledger as shape_ledger_svc
|
from scribe.services import shape_ledger as shape_ledger_svc
|
||||||
from scribe.services import snippets as snippets_svc
|
from scribe.services import snippets as snippets_svc
|
||||||
from scribe.services.access import label_shared_items, owner_names_for
|
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.embeddings import (
|
||||||
|
document_title,
|
||||||
|
semantic_search_notes,
|
||||||
|
semantic_search_rules,
|
||||||
|
)
|
||||||
from scribe.services.lessons import LESSON_NOTE_TYPE
|
from scribe.services.lessons import LESSON_NOTE_TYPE
|
||||||
from scribe.services.note_usage import record_surfaced
|
from scribe.services.note_usage import record_surfaced
|
||||||
from scribe.services.rule_usage import record_rule_surfaced
|
from scribe.services.rule_usage import record_rule_surfaced
|
||||||
@@ -40,6 +44,7 @@ from scribe.services.retrieval_surfaces import (
|
|||||||
)
|
)
|
||||||
from scribe.services.retrieval_telemetry import record_retrieval
|
from scribe.services.retrieval_telemetry import record_retrieval
|
||||||
from scribe.services.settings import get_setting
|
from scribe.services.settings import get_setting
|
||||||
|
from scribe.services.systems import system_names_for
|
||||||
from scribe.services.text import elide
|
from scribe.services.text import elide
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -47,23 +52,73 @@ logger = logging.getLogger(__name__)
|
|||||||
# Defensive cap below Claude Code's 10k additionalContext limit.
|
# Defensive cap below Claude Code's 10k additionalContext limit.
|
||||||
_MAX_CHARS = 9000
|
_MAX_CHARS = 9000
|
||||||
|
|
||||||
# Max chars of the matched passage shown under an injected menu line.
|
# WHAT A MENU LINE CARRIES (#4364): the record's NAME, its kind and System,
|
||||||
|
# and the WHOLE passage that matched. Metadata plus the evidence, rather than a
|
||||||
|
# title asked to be both.
|
||||||
#
|
#
|
||||||
# The menu used to be titles alone, on the reasoning that its job is AWARENESS —
|
# The name, not the title. A snippet's or lesson's title is `name — when it
|
||||||
# make the agent know the record exists and reach for it, not dump it. That
|
# applies` by construction (`embeddings.trigger_title`), because that join is
|
||||||
# holds for a lesson or a snippet, whose title carries its trigger by
|
# what makes it rank on its situation. That is an EMBEDDING shape, and rendered
|
||||||
# construction ("what — when it applies"). It does not hold for an issue, a
|
# as a menu line it ran to 1,500+ characters — the trigger paragraph spent
|
||||||
# dev-log or a plain note, where the title is a headline and the reason this
|
# again on every line, and again on every repeat. The trigger still arrives
|
||||||
# record matched is a sentence somewhere inside it. The reader was being asked
|
# when it is what matched: it is in the chunk, and `_menu_passage` hands it
|
||||||
# "is this worth opening?" and handed the one part of the record guaranteed not
|
# over when the title was the whole match.
|
||||||
# to answer it.
|
|
||||||
#
|
#
|
||||||
# 200 rather than more because this is a menu: eight lines at 200 is ~1.6KB,
|
# The whole passage, not 200 characters of it. The search already chose the
|
||||||
# which buys the decision without turning an awareness push into a dump. It is
|
# chunk that matched; the old cut kept its head and tail, and the head is the
|
||||||
# the PASSAGE THAT MATCHED, not the record's opening — the search already knows
|
# title every chunk is prefixed with — so the reader got the title twice and
|
||||||
# which one that is and used to throw it away (#4243) — so 200 characters here
|
# lost the middle, which is where the match was (lesson #4248). A chunk is at
|
||||||
# are worth far more than 200 characters of preamble.
|
# most ~1.4 KB (`embeddings._CHUNK_CHAR_BUDGET`), and it is shown once: a
|
||||||
_MENU_PASSAGE_CHARS = 200
|
# repeat is a one-line pointer (`_menu_seen_line`), not a second copy.
|
||||||
|
|
||||||
|
|
||||||
|
def _menu_name(title: str | None, note_type: str | None, data=None, body: str | None = "") -> str:
|
||||||
|
"""The record's name — its title without the trigger composed into it."""
|
||||||
|
title = (title or "(untitled)").replace("\n", " ").strip()
|
||||||
|
data = data if isinstance(data, dict) else {}
|
||||||
|
if note_type == "snippet":
|
||||||
|
from scribe.services.embeddings import TRIGGER_SEP
|
||||||
|
return (data.get("name") or title.partition(TRIGGER_SEP)[0]).strip() or title
|
||||||
|
if note_type == LESSON_NOTE_TYPE:
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from scribe.services.embeddings import untrigger_title
|
||||||
|
from scribe.services.lessons import lesson_trigger
|
||||||
|
trigger = lesson_trigger(SimpleNamespace(data=data, body=body or ""))
|
||||||
|
return untrigger_title(title, trigger).strip() or title
|
||||||
|
return title
|
||||||
|
|
||||||
|
|
||||||
|
def _menu_passage(title: str | None, chunk_text: str | None, name: str = "") -> str:
|
||||||
|
"""The matched chunk on one line, without the title it was embedded under.
|
||||||
|
|
||||||
|
Every chunk is `title\nsection` (`embeddings.embedding_text`), and `title`
|
||||||
|
here must be the EMBEDDED one (`embeddings.document_title`) — for a snippet
|
||||||
|
or lesson that is `name — trigger`, not the stored name — so the prefix is
|
||||||
|
stripped exactly. A chunk that WAS only the title — a short
|
||||||
|
record, or the head chunk of one — matched on the title, and for a
|
||||||
|
trigger-keyed kind the part of it the name line no longer shows is the
|
||||||
|
trigger: that is returned, because it is precisely what matched.
|
||||||
|
One line, so the menu's blockquote survives it.
|
||||||
|
"""
|
||||||
|
title = (title or "").strip()
|
||||||
|
text = (chunk_text or "").strip()
|
||||||
|
if title and text.startswith(title):
|
||||||
|
text = text[len(title):]
|
||||||
|
text = " ".join(text.split())
|
||||||
|
if not text and name and title.startswith(name) and title != name:
|
||||||
|
text = " ".join(title[len(name):].lstrip(" —-").split())
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _menu_label(kind: str, systems: list[str] | None) -> str:
|
||||||
|
"""`issue (done) · Plugin & hooks` — the kind, then where it belongs."""
|
||||||
|
return " · ".join([kind, *systems]) if systems else kind
|
||||||
|
|
||||||
|
|
||||||
|
def _menu_seen_line(note_id: int, kind: str, name: str) -> str:
|
||||||
|
"""A pointer to a record this session was already shown, not a copy of it."""
|
||||||
|
return f"> - #{note_id} [{kind} · seen] {name}"
|
||||||
|
|
||||||
# Max chars of a Process body to fold into the auto-surface description.
|
# Max chars of a Process body to fold into the auto-surface description.
|
||||||
_PROC_PREVIEW_CHARS = 200
|
_PROC_PREVIEW_CHARS = 200
|
||||||
@@ -537,6 +592,36 @@ _AUTOINJECT_BAND = 0.10
|
|||||||
# was an accident of which arm got a configurable budget first.
|
# was an accident of which arm got a configurable budget first.
|
||||||
_AUTOINJECT_MAX_TOP_K = MAX_BUDGET
|
_AUTOINJECT_MAX_TOP_K = MAX_BUDGET
|
||||||
|
|
||||||
|
# THE CONVERSATION BESIDE THE PROMPT (#4364). The operator's message is the
|
||||||
|
# only query this arm had, and mid-session it is mostly a follow-up — "yes do
|
||||||
|
# that", "now fix the filter" — that names nothing a record could match. The
|
||||||
|
# hook now sends the tail of the last assistant reply as `context`, and it is
|
||||||
|
# appended to a SHORT prompt only: a prompt that already says what it is about
|
||||||
|
# is the better query on its own, and diluting it is the one way this can make
|
||||||
|
# retrieval worse.
|
||||||
|
#
|
||||||
|
# Neither number costs a model token. The query is embedding input; what the
|
||||||
|
# session pays for is the menu, and the budget bounds that unchanged.
|
||||||
|
# 280 — about two sentences. Above it a prompt carries its own subject.
|
||||||
|
# 600 — the context cap. Prompt + context stays well inside bge-small's
|
||||||
|
# 512-token window, prompt FIRST, so truncation can only ever cut
|
||||||
|
# context and never the operator's words.
|
||||||
|
_AUTOINJECT_CONTEXT_PROMPT_MAX = 280
|
||||||
|
_AUTOINJECT_CONTEXT_MAX = 600
|
||||||
|
|
||||||
|
|
||||||
|
def _autoinject_query(prompt: str, context: str) -> str:
|
||||||
|
"""The prompt, with recent conversation appended when the prompt is thin.
|
||||||
|
|
||||||
|
The prompt leads and context is its tail, cut from the END of the reply —
|
||||||
|
a reply's closing lines are where it says what it did and what is next,
|
||||||
|
which is what the operator's follow-up is answering.
|
||||||
|
"""
|
||||||
|
ctx = " ".join((context or "").split())[-_AUTOINJECT_CONTEXT_MAX:]
|
||||||
|
if not ctx or len(prompt) > _AUTOINJECT_CONTEXT_PROMPT_MAX:
|
||||||
|
return prompt
|
||||||
|
return f"{prompt}\n\n{ctx}"
|
||||||
|
|
||||||
# --- the prompt-boundary rule arm (#3852) ------------------------------------
|
# --- the prompt-boundary rule arm (#3852) ------------------------------------
|
||||||
#
|
#
|
||||||
# Both existing rule arms are keyed on something the session is about to DO —
|
# Both existing rule arms are keyed on something the session is about to DO —
|
||||||
@@ -961,6 +1046,7 @@ async def build_autoinject_hint(
|
|||||||
query: str,
|
query: str,
|
||||||
project_id: int = 0,
|
project_id: int = 0,
|
||||||
exclude_ids: list[int] | None = None,
|
exclude_ids: list[int] | None = None,
|
||||||
|
context: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Title-first awareness hint for the plugin's UserPromptSubmit hook.
|
"""Title-first awareness hint for the plugin's UserPromptSubmit hook.
|
||||||
|
|
||||||
@@ -982,6 +1068,9 @@ async def build_autoinject_hint(
|
|||||||
q = (query or "").strip()
|
q = (query or "").strip()
|
||||||
if not cfg["enabled"] or not q:
|
if not cfg["enabled"] or not q:
|
||||||
return empty
|
return empty
|
||||||
|
# Everything below searches, logs and fills its slots on the ENRICHED
|
||||||
|
# query, so the telemetry row records what was actually asked (#4364).
|
||||||
|
q = _autoinject_query(q, context)
|
||||||
|
|
||||||
# THE LEDGER LEAVES THE SEARCH (#4101). `exclude_ids` used to go into
|
# THE LEDGER LEAVES THE SEARCH (#4101). `exclude_ids` used to go into
|
||||||
# `semantic_search_notes` itself, so a record this session had already been
|
# `semantic_search_notes` itself, so a record this session had already been
|
||||||
@@ -1081,8 +1170,9 @@ async def build_autoinject_hint(
|
|||||||
lines = [
|
lines = [
|
||||||
"> Possibly relevant from your Scribe records — open any in full with "
|
"> Possibly relevant from your Scribe records — open any in full with "
|
||||||
"`get_note(id)`, or `get_snippet` / `get_process` / `get_lesson` for "
|
"`get_note(id)`, or `get_snippet` / `get_process` / `get_lesson` for "
|
||||||
"those kinds (titles only; a line marked `seen` was surfaced earlier "
|
"those kinds. Each line is a record's name, its kind and System, and "
|
||||||
"this session and may no longer be in context):",
|
"the passage that matched; a line marked `seen` is a pointer to one "
|
||||||
|
"already shown this session, so it is in your context:",
|
||||||
]
|
]
|
||||||
# THE REGISTER, SAID ONCE AND ONLY WHEN IT APPLIES (milestone 385 step 5).
|
# THE REGISTER, SAID ONCE AND ONLY WHEN IT APPLIES (milestone 385 step 5).
|
||||||
#
|
#
|
||||||
@@ -1118,34 +1208,45 @@ async def build_autoinject_hint(
|
|||||||
# with the query that actually matched it.
|
# with the query that actually matched it.
|
||||||
menu_chunks = _rep_ai.get("best_chunk") or {}
|
menu_chunks = _rep_ai.get("best_chunk") or {}
|
||||||
|
|
||||||
|
systems = await system_names_for({int(n.id) for _s, n in kept if int(n.id) not in already})
|
||||||
note_ids: list[int] = []
|
note_ids: list[int] = []
|
||||||
for score, note in kept:
|
for score, note in kept:
|
||||||
note_ids.append(int(note.id))
|
nid = int(note.id)
|
||||||
title = (note.title or "(untitled)").replace("\n", " ").strip()
|
note_ids.append(nid)
|
||||||
line = f"> - #{note.id} [{_record_kind(note)}] \"{title}\" ({score:.2f})"
|
kind = _record_kind(note)
|
||||||
# ONE WORD, NOT A SENTENCE, and deliberately not the rule arms' phrasing.
|
# The NAME, not the title (#4364): a snippet's or lesson's title is its
|
||||||
# A rule line says "before deciding it does not apply", which is the
|
# embedding shape, trigger and all, and ran past 1,500 characters here.
|
||||||
# voice of a record that BINDS; a note binds nothing, and borrowing that
|
name = _menu_name(note.title, note.note_type, note.data, note.body)
|
||||||
# tone would tell the reader a dev-log has authority it does not have.
|
if nid in already:
|
||||||
# The header carries the meaning, so the line carries only the flag.
|
# A POINTER, not a copy (#4364). The record is in this session's
|
||||||
if int(note.id) in already:
|
# context already — the ledger is cleared at compaction, so "seen"
|
||||||
line += " [seen]"
|
# stays true — and re-rendering it spent its whole line again for
|
||||||
if int(note.id) in stale:
|
# nothing. What the reader needs is the reminder that it matched
|
||||||
|
# again, and the id to open it if it has scrolled out of mind.
|
||||||
|
line = _menu_seen_line(nid, kind, name)
|
||||||
|
if nid in stale:
|
||||||
|
line += " — SUPERSEDED"
|
||||||
|
lines.append(line)
|
||||||
|
continue
|
||||||
|
line = f"> - #{nid} [{_menu_label(kind, systems.get(nid))}] \"{name}\" ({score:.2f})"
|
||||||
|
if nid in stale:
|
||||||
line += " — SUPERSEDED, a later record covers this; check that first"
|
line += " — SUPERSEDED, a later record covers this; check that first"
|
||||||
if note.user_id != user_id:
|
if note.user_id != user_id:
|
||||||
who = owners.get(int(note.user_id)) or "another user"
|
who = owners.get(int(note.user_id)) or "another user"
|
||||||
line += f" — shared by {who}, treat as a suggestion"
|
line += f" — shared by {who}, treat as a suggestion"
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
# The passage that earned the line, indented under it. Absent when the
|
# The passage that earned the line, WHOLE, indented under it (#4364).
|
||||||
# record has no stored chunk — an un-embedded row, or the reserved
|
# Absent when the record has no stored chunk — an un-embedded row, or
|
||||||
# lesson and reuse slots, which are fetched by their own queries and so
|
# the reserved lesson and reuse slots, which are fetched by their own
|
||||||
# are not in this search's report. No fallback to the body's opening:
|
# queries and so are not in this search's report. No fallback to the
|
||||||
# on a menu that would be a line of preamble dressed as a reason, and a
|
# body's opening: on a menu that would be a line of preamble dressed as
|
||||||
# reader cannot tell the two apart once they are indented identically.
|
# a reason, and a reader cannot tell the two apart once indented alike.
|
||||||
passage = (menu_chunks.get(int(note.id)) or {}).get("text") or ""
|
passage = _menu_passage(
|
||||||
if passage.strip():
|
document_title(note.title, note.note_type, note.data, note.body),
|
||||||
short, _cut = elide(" ".join(passage.split()), _MENU_PASSAGE_CHARS)
|
(menu_chunks.get(nid) or {}).get("text"), name,
|
||||||
lines.append(f"> ↳ {short}")
|
)
|
||||||
|
if passage:
|
||||||
|
lines.append(f"> ↳ {passage}")
|
||||||
|
|
||||||
# Records what SURVIVED the margin gate, not what the ranker returned — the
|
# Records what SURVIVED the margin gate, not what the ranker returned — the
|
||||||
# menu the agent actually saw. retrieval_logs already holds the full
|
# menu the agent actually saw. retrieval_logs already holds the full
|
||||||
@@ -1286,6 +1387,7 @@ async def build_prompt_rule_hint(
|
|||||||
project_id: int = 0,
|
project_id: int = 0,
|
||||||
exclude_rule_ids: list[int] | None = None,
|
exclude_rule_ids: list[int] | None = None,
|
||||||
held_rule_ids: list[int] | None = None,
|
held_rule_ids: list[int] | None = None,
|
||||||
|
context: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Rules and preferences that may apply to what the operator just asked.
|
"""Rules and preferences that may apply to what the operator just asked.
|
||||||
|
|
||||||
@@ -1319,6 +1421,11 @@ async def build_prompt_rule_hint(
|
|||||||
q = (query or "").strip()
|
q = (query or "").strip()
|
||||||
if not q:
|
if not q:
|
||||||
return out
|
return out
|
||||||
|
# The same enrichment the notes arm gets (#4364). A rule is meant to
|
||||||
|
# arrive while the work it governs is under way, not once the operator
|
||||||
|
# names it — and "yes, go ahead" names nothing a trigger can match, while
|
||||||
|
# the reply it answers ("commit this to dev and push") does.
|
||||||
|
q = _autoinject_query(q, context)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
threshold = await floor_for(user_id, "prompt_rule")
|
threshold = await floor_for(user_id, "prompt_rule")
|
||||||
@@ -1429,7 +1536,12 @@ def _prior_art_line(item: dict, marker: str, owner: str | None, foreign_lang: st
|
|||||||
rather than appended after the title, so the reader sees it while still
|
rather than appended after the title, so the reader sees it while still
|
||||||
reading the score — the two together are the judgement being offered.
|
reading the score — the two together are the judgement being offered.
|
||||||
"""
|
"""
|
||||||
title = (item.get("title") or "(untitled)").replace("\n", " ").strip()
|
# The NAME, not the composed title (#4364) — a snippet's title carries its
|
||||||
|
# whole trigger and ran to kilobytes on this line, again on every repeat.
|
||||||
|
title = (
|
||||||
|
item.get("name") or (item.get("snippet") or {}).get("name")
|
||||||
|
or (item.get("title") or "(untitled)")
|
||||||
|
).replace("\n", " ").strip()
|
||||||
mark = f"{marker} · {foreign_lang}" if foreign_lang else marker
|
mark = f"{marker} · {foreign_lang}" if foreign_lang else marker
|
||||||
line = f"> - #{item['id']} [{mark}] \"{title}\""
|
line = f"> - #{item['id']} [{mark}] \"{title}\""
|
||||||
if owner:
|
if owner:
|
||||||
@@ -2038,6 +2150,7 @@ async def build_write_path_hint(
|
|||||||
# dropped — decided by which arm happened to find them — is worse than
|
# dropped — decided by which arm happened to find them — is worse than
|
||||||
# either rule applied consistently: the marker would read as a complete
|
# either rule applied consistently: the marker would read as a complete
|
||||||
# account of what the session has met before, and it would not be one.
|
# account of what the session has met before, and it would not be one.
|
||||||
|
item["seen"] = nid in excluded
|
||||||
placed.append(("nearby · seen" if nid in excluded else "nearby", item))
|
placed.append(("nearby · seen" if nid in excluded else "nearby", item))
|
||||||
|
|
||||||
# The stamping feed's "actually pulled it" half (#2791). Read once, before
|
# The stamping feed's "actually pulled it" half (#2791). Read once, before
|
||||||
@@ -2215,6 +2328,16 @@ async def build_write_path_hint(
|
|||||||
marker,
|
marker,
|
||||||
{
|
{
|
||||||
"id": int(note.id), "title": note.title, "user_id": note.user_id,
|
"id": int(note.id), "title": note.title, "user_id": note.user_id,
|
||||||
|
# The name the line shows, and whether this session has
|
||||||
|
# it already — carried as data for the reason `kind` is
|
||||||
|
# (#4364): the line is built from facts, not from
|
||||||
|
# re-reading its own marker.
|
||||||
|
"name": _menu_name(note.title, note.note_type, note.data, note.body),
|
||||||
|
# What its chunks are prefixed with, for stripping.
|
||||||
|
"doc_title": document_title(
|
||||||
|
note.title, note.note_type, note.data, note.body,
|
||||||
|
),
|
||||||
|
"seen": int(note.id) in excluded,
|
||||||
# Carried, not re-read off the rendered marker. The
|
# Carried, not re-read off the rendered marker. The
|
||||||
# marker is prose assembled for a human and it already
|
# marker is prose assembled for a human and it already
|
||||||
# varies by kind, language and the `seen` flag — a
|
# varies by kind, language and the `seen` flag — a
|
||||||
@@ -2359,8 +2482,9 @@ async def build_write_path_hint(
|
|||||||
"`get_lesson(id)` for a lesson, `get_note(id)` otherwise. Reuse a "
|
"`get_lesson(id)` for a lesson, `get_note(id)` otherwise. Reuse a "
|
||||||
"snippet rather than writing a fresh one-off; read an issue before "
|
"snippet rather than writing a fresh one-off; read an issue before "
|
||||||
"repeating what it records "
|
"repeating what it records "
|
||||||
"(titles only; a line marked `seen` was surfaced earlier this "
|
"(each line is a record's name and kind, with the passage that "
|
||||||
"session and may no longer be in context):"
|
"matched; a line marked `seen` is a pointer to one already shown "
|
||||||
|
"this session, so it is in your context):"
|
||||||
)
|
)
|
||||||
# The same clause the prompt menu carries, on the same condition and for
|
# The same clause the prompt menu carries, on the same condition and for
|
||||||
# the same reason: this menu's three other kinds are all things that WERE
|
# the same reason: this menu's three other kinds are all things that WERE
|
||||||
@@ -2394,10 +2518,17 @@ async def build_write_path_hint(
|
|||||||
# is no matching passage and the body's opening would be a fabricated
|
# is no matching passage and the body's opening would be a fabricated
|
||||||
# reason. Absence here is meaningful: a line with no passage under it is
|
# reason. Absence here is meaningful: a line with no passage under it is
|
||||||
# one that earned its place by where it lives, not by what it says.
|
# one that earned its place by where it lives, not by what it says.
|
||||||
passage = (wp_chunks.get(int(item["id"])) or {}).get("text") or ""
|
# WHOLE, and only on first sight (#4364): a `seen` line is a pointer to
|
||||||
if passage.strip():
|
# a record already in context, and its passage is already there too.
|
||||||
short, _cut = elide(" ".join(passage.split()), _MENU_PASSAGE_CHARS)
|
if item.get("seen"):
|
||||||
lines.append(f"> ↳ {short}")
|
continue
|
||||||
|
passage = _menu_passage(
|
||||||
|
item.get("doc_title") or item.get("title"),
|
||||||
|
(wp_chunks.get(int(item["id"])) or {}).get("text"),
|
||||||
|
item.get("name") or "",
|
||||||
|
)
|
||||||
|
if passage:
|
||||||
|
lines.append(f"> ↳ {passage}")
|
||||||
|
|
||||||
if stamped:
|
if stamped:
|
||||||
lines.append(_stamp_line(path, stamped))
|
lines.append(_stamp_line(path, stamped))
|
||||||
|
|||||||
@@ -54,18 +54,6 @@ UNSET: object = object()
|
|||||||
|
|
||||||
# --- serialize: structured fields -> note (title/body/tags) ------------------
|
# --- serialize: structured fields -> note (title/body/tags) ------------------
|
||||||
|
|
||||||
def compose_title(name: str, when_to_use: str = "") -> str:
|
|
||||||
"""`name — when to use` (or just `name` when no usage note is given).
|
|
||||||
|
|
||||||
The join itself lives in `embeddings.trigger_title`, which rules and
|
|
||||||
lessons build their titles from too. Kept as a named function here because
|
|
||||||
it is this module's public vocabulary and callers say `compose_title`.
|
|
||||||
"""
|
|
||||||
from scribe.services.embeddings import trigger_title
|
|
||||||
|
|
||||||
return trigger_title(name, when_to_use)
|
|
||||||
|
|
||||||
|
|
||||||
def compose_tags(language: str = "", tags: list[str] | None = None) -> list[str]:
|
def compose_tags(language: str = "", tags: list[str] | None = None) -> list[str]:
|
||||||
"""Language (lowercased) first, then the `snippet` marker, then caller tags —
|
"""Language (lowercased) first, then the `snippet` marker, then caller tags —
|
||||||
de-duplicated, order preserved."""
|
de-duplicated, order preserved."""
|
||||||
@@ -741,7 +729,7 @@ async def create_snippet(
|
|||||||
locations = resolve_locations(repo, path, symbol, locations)
|
locations = resolve_locations(repo, path, symbol, locations)
|
||||||
note = await notes_svc.create_note(
|
note = await notes_svc.create_note(
|
||||||
user_id,
|
user_id,
|
||||||
title=compose_title(name, when_to_use),
|
title=name.strip(),
|
||||||
body=compose_body(
|
body=compose_body(
|
||||||
code=code, language=language, signature=signature,
|
code=code, language=language, signature=signature,
|
||||||
when_to_use=when_to_use, locations=locations,
|
when_to_use=when_to_use, locations=locations,
|
||||||
@@ -898,7 +886,7 @@ async def update_snippet(
|
|||||||
provenance = cur.get("provenance")
|
provenance = cur.get("provenance")
|
||||||
|
|
||||||
fields: dict = {
|
fields: dict = {
|
||||||
"title": compose_title(merged["name"], merged["when_to_use"]),
|
"title": (merged["name"] or "").strip(),
|
||||||
"body": compose_body(
|
"body": compose_body(
|
||||||
code=merged["code"], language=merged["language"],
|
code=merged["code"], language=merged["language"],
|
||||||
signature=merged["signature"], when_to_use=merged["when_to_use"],
|
signature=merged["signature"], when_to_use=merged["when_to_use"],
|
||||||
|
|||||||
@@ -310,6 +310,39 @@ async def list_record_systems(user_id: int, note_id: int) -> list[System]:
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def system_names_for(note_ids: set[int]) -> dict[int, list[str]]:
|
||||||
|
"""{note_id: [system name, …]} in one query, for records ALREADY read.
|
||||||
|
|
||||||
|
For decorating a result set the caller was allowed to see — an injected
|
||||||
|
menu line says which part of the project a record is about, so the reader
|
||||||
|
can place it without opening it (#4364). No access check here for that
|
||||||
|
reason: the ids come from a search that applied one, and a system name is
|
||||||
|
metadata of the record, not a record of its own.
|
||||||
|
|
||||||
|
Fails soft, like `access.owner_names_for`: a menu without its system labels
|
||||||
|
is a cosmetic downgrade, and failing the whole injection over one is not.
|
||||||
|
"""
|
||||||
|
if not note_ids:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(RecordSystem.note_id, System.name)
|
||||||
|
.join(System, System.id == RecordSystem.system_id)
|
||||||
|
.where(RecordSystem.note_id.in_(note_ids), System.deleted_at.is_(None))
|
||||||
|
.order_by(System.order_index.asc(), System.name.asc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("System-name lookup failed; menu lines go unlabelled", exc_info=True)
|
||||||
|
return {}
|
||||||
|
out: dict[int, list[str]] = {}
|
||||||
|
for note_id, name in rows:
|
||||||
|
out.setdefault(int(note_id), []).append(name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def list_records_for_system(
|
async def list_records_for_system(
|
||||||
user_id: int, system_id: int, kind: str | None = None, open_only: bool = False
|
user_id: int, system_id: int, kind: str | None = None, open_only: bool = False
|
||||||
) -> list[Note]:
|
) -> list[Note]:
|
||||||
|
|||||||
@@ -102,6 +102,20 @@ def _no_supersession():
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_system_labels():
|
||||||
|
"""Stub the menu's "which System is each line about?" lookup (#4364).
|
||||||
|
|
||||||
|
Autouse because every test that renders an injected menu reaches it, and
|
||||||
|
it is a real database call on a path those tests run without one. Stubbed
|
||||||
|
to "no labels", the state of any untagged record. Tests of the label
|
||||||
|
itself patch it with a value.
|
||||||
|
"""
|
||||||
|
with patch("scribe.services.plugin_context.system_names_for",
|
||||||
|
AsyncMock(return_value={})):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _no_task_log_arm():
|
def _no_task_log_arm():
|
||||||
"""Stub the task-log read arm that get_task / list_tasks / get_milestone
|
"""Stub the task-log read arm that get_task / list_tasks / get_milestone
|
||||||
|
|||||||
+9
-10
@@ -212,10 +212,10 @@ def fake_snippet(**attrs) -> MagicMock:
|
|||||||
def fake_lesson(**attrs) -> MagicMock:
|
def fake_lesson(**attrs) -> MagicMock:
|
||||||
"""A stand-in lesson: a note whose `note_type` is what makes it one.
|
"""A stand-in lesson: a note whose `note_type` is what makes it one.
|
||||||
|
|
||||||
The title carries the trigger because `compose_title` builds it that way —
|
The title is the subject alone and the trigger lives in `data`, because
|
||||||
`{what} — {when it applies}` — so a menu line rendering only the title is
|
that is the record the product creates (milestone 427) — the trigger joins
|
||||||
already showing the reader when this lesson applies. Tests that used a bare
|
the title only in the embedded document. A default whose title carried the
|
||||||
title here would be testing a record the product cannot create.
|
trigger would be testing a row only an un-migrated database holds.
|
||||||
|
|
||||||
The check fields and `arose_from_id` are explicitly None for the reason
|
The check fields and `arose_from_id` are explicitly None for the reason
|
||||||
`fake_snippet`'s `data` is: `update_note` reads `verify_with` and
|
`fake_snippet`'s `data` is: `update_note` reads `verify_with` and
|
||||||
@@ -223,12 +223,11 @@ def fake_lesson(**attrs) -> MagicMock:
|
|||||||
auto-created MagicMock attribute is truthy — so a default lesson driven
|
auto-created MagicMock attribute is truthy — so a default lesson driven
|
||||||
through the update path would take a branch no real record takes.
|
through the update path would take a branch no real record takes.
|
||||||
"""
|
"""
|
||||||
attrs.setdefault(
|
attrs.setdefault("title", "Give absolutely-positioned siblings an explicit stacking order")
|
||||||
"title",
|
attrs.setdefault("data", {
|
||||||
"Give absolutely-positioned siblings an explicit stacking order — "
|
"what": "Give absolutely-positioned siblings an explicit stacking order",
|
||||||
"placing two absolutely-positioned elements in the same area",
|
"when_to_apply": "placing two absolutely-positioned elements in the same area",
|
||||||
)
|
})
|
||||||
attrs.setdefault("data", {"when_to_apply": "two absolute siblings overlap"})
|
|
||||||
attrs.setdefault("status", None)
|
attrs.setdefault("status", None)
|
||||||
attrs.setdefault("arose_from_id", None)
|
attrs.setdefault("arose_from_id", None)
|
||||||
attrs.setdefault("verify_with", None)
|
attrs.setdefault("verify_with", None)
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""The auto-inject query carries the conversation beside a thin prompt (#4364).
|
||||||
|
|
||||||
|
The notes arm used to retrieve against the operator's typed words alone, and a
|
||||||
|
mid-session follow-up — "yes do that", "now fix the filter" — names nothing a
|
||||||
|
record can match. The hook now reads the tail of the last assistant reply out
|
||||||
|
of the transcript and sends it as `ctx`; the server appends it to a SHORT
|
||||||
|
prompt only. Two halves, pinned separately: what the server builds, and what
|
||||||
|
the hook extracts.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scribe.services.plugin_context import (
|
||||||
|
_AUTOINJECT_CONTEXT_MAX,
|
||||||
|
_AUTOINJECT_CONTEXT_PROMPT_MAX,
|
||||||
|
_autoinject_query,
|
||||||
|
_menu_name,
|
||||||
|
_menu_passage,
|
||||||
|
)
|
||||||
|
from tests.helpers import fake_note, need_tools
|
||||||
|
|
||||||
|
DEFS = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "scribe_defs.sh"
|
||||||
|
|
||||||
|
|
||||||
|
# --- the query the server builds ---------------------------------------------
|
||||||
|
|
||||||
|
def test_a_short_prompt_is_followed_by_the_context():
|
||||||
|
q = _autoinject_query("yes do that", "I'll move the library filters into the side column.")
|
||||||
|
assert q.startswith("yes do that\n\n")
|
||||||
|
assert "library filters" in q
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_prompt_that_says_what_it_is_about_is_left_alone():
|
||||||
|
long_prompt = "x" * (_AUTOINJECT_CONTEXT_PROMPT_MAX + 1)
|
||||||
|
assert _autoinject_query(long_prompt, "anything at all") == long_prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_context_is_the_prompt_unchanged():
|
||||||
|
assert _autoinject_query("yes do that", "") == "yes do that"
|
||||||
|
assert _autoinject_query("yes do that", " \n ") == "yes do that"
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_is_cut_from_the_end_of_the_reply():
|
||||||
|
# The reply's close is where it says what it did and what is next — the
|
||||||
|
# part the operator's follow-up is answering.
|
||||||
|
ctx = "HEAD " + "m" * 2000 + " TAIL"
|
||||||
|
q = _autoinject_query("ok", ctx)
|
||||||
|
tail = q.split("\n\n", 1)[1]
|
||||||
|
assert len(tail) == _AUTOINJECT_CONTEXT_MAX
|
||||||
|
assert tail.endswith("TAIL") and "HEAD" not in tail
|
||||||
|
|
||||||
|
|
||||||
|
# --- what the hook extracts --------------------------------------------------
|
||||||
|
|
||||||
|
def _rec(role: str, blocks: list[dict], sidechain: bool = False) -> str:
|
||||||
|
return json.dumps({
|
||||||
|
"type": role, "isSidechain": sidechain,
|
||||||
|
"message": {"role": role, "content": blocks},
|
||||||
|
}, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def recent(tmp_path: Path, lines: list[str]) -> str:
|
||||||
|
need_tools("bash", "awk", "grep", "tail")
|
||||||
|
t = tmp_path / "t.jsonl"
|
||||||
|
t.write_text("\n".join(lines) + "\n")
|
||||||
|
r = subprocess.run(
|
||||||
|
["bash", "-c", f'set -uo pipefail\n. "{DEFS}"\nscribe_recent_context "{t}"'],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
assert r.returncode == 0, r.stderr.decode()
|
||||||
|
return r.stdout.decode().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_hook_reads_the_last_assistant_text(tmp_path):
|
||||||
|
out = recent(tmp_path, [
|
||||||
|
_rec("assistant", [{"type": "text", "text": "an older reply"}]),
|
||||||
|
_rec("assistant", [{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}]),
|
||||||
|
_rec("assistant", [{"type": "text", "text": "Shall I file it as an issue?\nNext: the hook."}]),
|
||||||
|
])
|
||||||
|
assert out == "Shall I file it as an issue? Next: the hook."
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_subagent_reply_is_not_this_session(tmp_path):
|
||||||
|
out = recent(tmp_path, [
|
||||||
|
_rec("assistant", [{"type": "text", "text": "the session's reply"}]),
|
||||||
|
_rec("assistant", [{"type": "text", "text": "a subagent's report"}], sidechain=True),
|
||||||
|
])
|
||||||
|
assert out == "the session's reply"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_hook_caps_what_it_sends(tmp_path):
|
||||||
|
out = recent(tmp_path, [_rec("assistant", [{"type": "text", "text": "a" * 5000 + "END"}])])
|
||||||
|
assert len(out) == 600 and out.endswith("END")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_transcript_is_silence_not_failure(tmp_path):
|
||||||
|
assert recent(tmp_path, []) == ""
|
||||||
|
|
||||||
|
|
||||||
|
# --- what a menu line carries (#4364) ----------------------------------------
|
||||||
|
#
|
||||||
|
# The name, the kind and System, and the WHOLE matched passage — once. A repeat
|
||||||
|
# is a pointer. These pin the shape against the three ways it had gone wrong:
|
||||||
|
# a trigger-composed title rendered as the line (1,500+ chars), the passage cut
|
||||||
|
# to its head (which was the title again), and a `seen` repeat re-rendered whole.
|
||||||
|
|
||||||
|
TRIGGER = "Adding a record type that is semantically searchable. " * 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_snippet_line_shows_its_name_not_its_trigger():
|
||||||
|
assert _menu_name(f"embed_x — {TRIGGER}", "snippet", {"name": "embed_x"}) == "embed_x"
|
||||||
|
# With no mirror, the first separator is the seam (snippets.py's inverse).
|
||||||
|
assert _menu_name(f"embed_x — {TRIGGER}", "snippet", None) == "embed_x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_lesson_line_shows_its_subject_not_its_trigger():
|
||||||
|
title = f"A guard does not undo a stored value — {TRIGGER.strip()}"
|
||||||
|
name = _menu_name(title, "lesson", {"when_to_apply": TRIGGER.strip()})
|
||||||
|
assert name == "A guard does not undo a stored value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plain_note_keeps_its_title_dashes_and_all():
|
||||||
|
t = "Dev-log 2026-07-29 — milestone #232 closed"
|
||||||
|
assert _menu_name(t, "note", None) == t
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_passage_is_the_whole_chunk_without_its_title_prefix():
|
||||||
|
body = "section " * 150 # ~1.2 KB: nothing of it is cut
|
||||||
|
out = _menu_passage("Pool sizing", f"Pool sizing\n{body}\nsecond line")
|
||||||
|
assert not out.startswith("Pool sizing")
|
||||||
|
assert out.endswith("second line") and "\n" not in out
|
||||||
|
assert len(out) > 1100
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_title_only_match_hands_over_the_trigger_it_matched_on():
|
||||||
|
title = "embed_x — when adding a searchable record"
|
||||||
|
assert _menu_passage(title, title, "embed_x") == "when adding a searchable record"
|
||||||
|
|
||||||
|
|
||||||
|
async def _menu(hits, seen, chunks, systems=None):
|
||||||
|
from scribe.services import plugin_context as pc
|
||||||
|
|
||||||
|
calls: list[int] = []
|
||||||
|
|
||||||
|
async def _search(*_a, **kw):
|
||||||
|
calls.append(1)
|
||||||
|
if len(calls) > 1:
|
||||||
|
return []
|
||||||
|
if kw.get("report") is not None:
|
||||||
|
kw["report"]["best_chunk"] = chunks
|
||||||
|
return hits
|
||||||
|
|
||||||
|
with patch.object(pc, "get_autoinject_config",
|
||||||
|
AsyncMock(return_value={"enabled": True, "threshold": 0.55, "top_k": 3})), \
|
||||||
|
patch.object(pc, "semantic_search_notes", _search), \
|
||||||
|
patch.object(pc, "superseded_ids", AsyncMock(return_value=set())), \
|
||||||
|
patch.object(pc, "system_names_for", AsyncMock(return_value=systems or {})), \
|
||||||
|
patch.object(pc, "record_retrieval", MagicMock()), \
|
||||||
|
patch.object(pc, "record_surfaced", MagicMock()):
|
||||||
|
return (await pc.build_autoinject_hint(1, "q", project_id=2, exclude_ids=seen))["context"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_first_sighting_carries_name_system_and_passage():
|
||||||
|
title = f"embed_x — {TRIGGER}"
|
||||||
|
hits = [(0.8, fake_note(id=11, title=title, note_type="snippet",
|
||||||
|
data={"name": "embed_x"}, user_id=1))]
|
||||||
|
out = await _menu(hits, [], {11: {"index": 1, "text": f"{title}\nthe matched section"}},
|
||||||
|
systems={11: ["Retrieval & recall"]})
|
||||||
|
line = next(ln for ln in out.splitlines() if "#11" in ln)
|
||||||
|
assert '[snippet · Retrieval & recall] "embed_x"' in line
|
||||||
|
assert TRIGGER[:40] not in line
|
||||||
|
assert "> ↳ the matched section" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_seen_record_is_a_pointer_not_a_copy():
|
||||||
|
title = f"embed_x — {TRIGGER}"
|
||||||
|
hits = [(0.8, fake_note(id=11, title=title, note_type="snippet",
|
||||||
|
data={"name": "embed_x"}, user_id=1))]
|
||||||
|
out = await _menu(hits, [11], {11: {"index": 1, "text": f"{title}\nthe matched section"}})
|
||||||
|
line = next(ln for ln in out.splitlines() if "#11" in ln)
|
||||||
|
assert line == "> - #11 [snippet · seen] embed_x"
|
||||||
|
assert "↳" not in out
|
||||||
@@ -56,7 +56,7 @@ async def test_the_backfill_embeds_the_text_as_it_is_now_not_as_it_was_scanned()
|
|||||||
with (
|
with (
|
||||||
patch.object(emb, "async_session", return_value=_ctx(scan)),
|
patch.object(emb, "async_session", return_value=_ctx(scan)),
|
||||||
patch.object(emb, "_current_row",
|
patch.object(emb, "_current_row",
|
||||||
AsyncMock(return_value=(42, "T", *edited))),
|
AsyncMock(return_value=(42, "T", *edited, "note", None))),
|
||||||
patch.object(emb, "upsert_note_embedding", AsyncMock()) as upsert,
|
patch.object(emb, "upsert_note_embedding", AsyncMock()) as upsert,
|
||||||
patch.object(emb.asyncio, "sleep", AsyncMock()),
|
patch.object(emb.asyncio, "sleep", AsyncMock()),
|
||||||
):
|
):
|
||||||
@@ -75,7 +75,7 @@ async def test_a_record_deleted_between_the_scan_and_the_loop_is_skipped():
|
|||||||
with (
|
with (
|
||||||
patch.object(emb, "async_session", return_value=_ctx(scan)),
|
patch.object(emb, "async_session", return_value=_ctx(scan)),
|
||||||
patch.object(emb, "_current_row",
|
patch.object(emb, "_current_row",
|
||||||
AsyncMock(side_effect=[None, (42, "T", "body")])),
|
AsyncMock(side_effect=[None, (42, "T", "body", "note", None)])),
|
||||||
patch.object(emb, "upsert_note_embedding", AsyncMock()) as upsert,
|
patch.object(emb, "upsert_note_embedding", AsyncMock()) as upsert,
|
||||||
patch.object(emb.asyncio, "sleep", AsyncMock()),
|
patch.object(emb.asyncio, "sleep", AsyncMock()),
|
||||||
):
|
):
|
||||||
@@ -99,7 +99,7 @@ async def test_a_record_whose_text_outran_its_vectors_is_re_embedded():
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(emb, "async_session", return_value=_ctx(scan)),
|
patch.object(emb, "async_session", return_value=_ctx(scan)),
|
||||||
patch.object(emb, "_current_row", AsyncMock(return_value=(42, "T", "b"))),
|
patch.object(emb, "_current_row", AsyncMock(return_value=(42, "T", "b", "note", None))),
|
||||||
patch.object(emb, "upsert_note_embedding", AsyncMock()) as upsert,
|
patch.object(emb, "upsert_note_embedding", AsyncMock()) as upsert,
|
||||||
patch.object(emb.asyncio, "sleep", AsyncMock()),
|
patch.object(emb.asyncio, "sleep", AsyncMock()),
|
||||||
):
|
):
|
||||||
@@ -119,7 +119,7 @@ async def test_a_task_logged_since_its_vectors_is_re_embedded():
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(emb, "async_session", return_value=_ctx(scan)),
|
patch.object(emb, "async_session", return_value=_ctx(scan)),
|
||||||
patch.object(emb, "_current_row", AsyncMock(return_value=(42, "T", "b"))),
|
patch.object(emb, "_current_row", AsyncMock(return_value=(42, "T", "b", "note", None))),
|
||||||
patch.object(emb, "upsert_note_embedding", AsyncMock()) as upsert,
|
patch.object(emb, "upsert_note_embedding", AsyncMock()) as upsert,
|
||||||
patch.object(emb.asyncio, "sleep", AsyncMock()),
|
patch.object(emb.asyncio, "sleep", AsyncMock()),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ async def test_backfill_reembeds_notes_with_a_stale_chunker_version():
|
|||||||
with (
|
with (
|
||||||
patch.object(emb, "async_session", return_value=ctx),
|
patch.object(emb, "async_session", return_value=ctx),
|
||||||
patch.object(emb, "_current_row",
|
patch.object(emb, "_current_row",
|
||||||
AsyncMock(return_value=(42, "stale-version", "body"))),
|
AsyncMock(return_value=(42, "stale-version", "body", "note", None))),
|
||||||
patch.object(emb, "upsert_note_embedding", AsyncMock()) as upsert,
|
patch.object(emb, "upsert_note_embedding", AsyncMock()) as upsert,
|
||||||
patch.object(emb.asyncio, "sleep", AsyncMock()),
|
patch.object(emb.asyncio, "sleep", AsyncMock()),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -164,11 +164,11 @@ def _lesson_body(trigger, insight="Read the job log.", sources=None):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_body_write_moves_a_lessons_trigger_with_it():
|
async def test_a_body_write_moves_a_lessons_trigger_with_it():
|
||||||
from scribe.services.lessons import TRIGGER_KEY, compose_title
|
from scribe.services.lessons import TRIGGER_KEY
|
||||||
|
|
||||||
what = "Read the job log before waiting longer"
|
what = "Read the job log before waiting longer"
|
||||||
note = fake_lesson(
|
note = fake_lesson(
|
||||||
title=compose_title(what, NEW_TRIGGER),
|
title=what,
|
||||||
data={TRIGGER_KEY: "a CI run is slow", "what": what},
|
data={TRIGGER_KEY: "a CI run is slow", "what": what},
|
||||||
project_id=None,
|
project_id=None,
|
||||||
)
|
)
|
||||||
@@ -183,13 +183,16 @@ async def test_a_body_write_moves_a_lessons_trigger_with_it():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_subject_containing_an_em_dash_still_splits():
|
async def test_a_subject_containing_an_em_dash_still_splits():
|
||||||
"""Why `untrigger_title` is given the trigger instead of splitting on the
|
"""Why `untrigger_title` is given the trigger instead of splitting on the
|
||||||
separator: a subject may legitimately contain one."""
|
separator: a subject may legitimately contain one. The title here is an
|
||||||
from scribe.services.lessons import TRIGGER_KEY, compose_title
|
UN-MIGRATED one, still carrying its trigger (milestone 427), because that
|
||||||
|
is the row the inverse still has to read correctly."""
|
||||||
|
from scribe.services.embeddings import trigger_title
|
||||||
|
from scribe.services.lessons import TRIGGER_KEY
|
||||||
|
|
||||||
what = "A wait with no deadline — the shape, not the symptom"
|
what = "A wait with no deadline — the shape, not the symptom"
|
||||||
trigger = "you are about to await something crossing a process boundary"
|
trigger = "you are about to await something crossing a process boundary"
|
||||||
note = fake_lesson(
|
note = fake_lesson(
|
||||||
title=compose_title(what, trigger), data=None, project_id=None,
|
title=trigger_title(what, trigger), data=None, project_id=None,
|
||||||
)
|
)
|
||||||
await _update(note, body=_lesson_body(trigger))
|
await _update(note, body=_lesson_body(trigger))
|
||||||
assert note.data["what"] == what
|
assert note.data["what"] == what
|
||||||
@@ -202,10 +205,10 @@ async def test_dropping_the_provenance_line_drops_it_from_the_mirror():
|
|||||||
the failure this recompose exists to prevent, not a courtesy — the
|
the failure this recompose exists to prevent, not a courtesy — the
|
||||||
opposite call from a snippet's `verification`, which is carried because it
|
opposite call from a snippet's `verification`, which is carried because it
|
||||||
was never in the body to delete."""
|
was never in the body to delete."""
|
||||||
from scribe.services.lessons import SOURCES_KEY, compose_title
|
from scribe.services.lessons import SOURCES_KEY
|
||||||
|
|
||||||
note = fake_lesson(
|
note = fake_lesson(
|
||||||
title=compose_title("Something learned", "a situation"),
|
title="Something learned",
|
||||||
data={SOURCES_KEY: [999]},
|
data={SOURCES_KEY: [999]},
|
||||||
project_id=None,
|
project_id=None,
|
||||||
)
|
)
|
||||||
@@ -229,7 +232,7 @@ async def test_an_explicit_data_wins_for_a_lesson_too():
|
|||||||
async def test_a_lesson_title_change_reaches_the_mirror():
|
async def test_a_lesson_title_change_reaches_the_mirror():
|
||||||
"""A lesson's subject lives in its title, so a title edit is a trigger for
|
"""A lesson's subject lives in its title, so a title edit is a trigger for
|
||||||
recomposition exactly as it is for a snippet's name."""
|
recomposition exactly as it is for a snippet's name."""
|
||||||
from scribe.services.lessons import TRIGGER_KEY, compose_title
|
from scribe.services.lessons import TRIGGER_KEY
|
||||||
|
|
||||||
trigger = "two absolute siblings overlap"
|
trigger = "two absolute siblings overlap"
|
||||||
note = fake_lesson(
|
note = fake_lesson(
|
||||||
@@ -237,7 +240,7 @@ async def test_a_lesson_title_change_reaches_the_mirror():
|
|||||||
data={TRIGGER_KEY: trigger, "what": "the old subject"},
|
data={TRIGGER_KEY: trigger, "what": "the old subject"},
|
||||||
project_id=None,
|
project_id=None,
|
||||||
)
|
)
|
||||||
await _update(note, title=compose_title("the new subject", trigger))
|
await _update(note, title="the new subject")
|
||||||
assert note.data["what"] == "the new subject"
|
assert note.data["what"] == "the new subject"
|
||||||
assert note.data[TRIGGER_KEY] == trigger
|
assert note.data[TRIGGER_KEY] == trigger
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ async def test_a_lesson_is_a_row_the_database_accepts(owner_id):
|
|||||||
value, and stays correct if it is gated with it."""
|
value, and stays correct if it is gated with it."""
|
||||||
lesson = await notes_svc.create_note(
|
lesson = await notes_svc.create_note(
|
||||||
owner_id,
|
owner_id,
|
||||||
title=lessons_svc.compose_title(SUBJECT, TRIGGER),
|
title=SUBJECT,
|
||||||
body=f"**When to apply:** {TRIGGER}\n\nOne change at a time.",
|
body=f"**When to apply:** {TRIGGER}\n\nOne change at a time.",
|
||||||
note_type=lessons_svc.LESSON_NOTE_TYPE,
|
note_type=lessons_svc.LESSON_NOTE_TYPE,
|
||||||
data={lessons_svc.TRIGGER_KEY: TRIGGER},
|
data={lessons_svc.TRIGGER_KEY: TRIGGER},
|
||||||
@@ -83,7 +83,8 @@ async def test_a_lesson_is_a_row_the_database_accepts(owner_id):
|
|||||||
# and the readable body — because the vector is built from the text and
|
# and the readable body — because the vector is built from the text and
|
||||||
# the queries are built from the mirror.
|
# the queries are built from the mirror.
|
||||||
assert lessons_svc.lesson_trigger(stored) == TRIGGER
|
assert lessons_svc.lesson_trigger(stored) == TRIGGER
|
||||||
assert stored.title == f"{SUBJECT} — {TRIGGER}"
|
# The subject alone (milestone 427): the trigger is in `data` and the body.
|
||||||
|
assert stored.title == SUBJECT
|
||||||
assert "**When to apply:**" in (stored.body or "")
|
assert "**When to apply:**" in (stored.body or "")
|
||||||
|
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ async def test_a_lesson_is_not_a_task(owner_id):
|
|||||||
"""
|
"""
|
||||||
lesson = await notes_svc.create_note(
|
lesson = await notes_svc.create_note(
|
||||||
owner_id,
|
owner_id,
|
||||||
title=lessons_svc.compose_title(SUBJECT, TRIGGER),
|
title=SUBJECT,
|
||||||
body="One change at a time.",
|
body="One change at a time.",
|
||||||
note_type=lessons_svc.LESSON_NOTE_TYPE,
|
note_type=lessons_svc.LESSON_NOTE_TYPE,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""The document a lesson is embedded as (milestone 385 step 3).
|
"""The document a lesson is embedded as (milestone 385 step 3; milestone 427).
|
||||||
|
|
||||||
WHY THIS IS THE STEP THAT DECIDES THE MILESTONE
|
WHY THIS IS THE STEP THAT DECIDES THE MILESTONE
|
||||||
|
|
||||||
@@ -7,26 +7,20 @@ as ordinary prose is a note wearing a label: it would look right in every
|
|||||||
listing and simply never be retrieved at the moment it applies, and nothing
|
listing and simply never be retrieved at the moment it applies, and nothing
|
||||||
anywhere would report that.
|
anywhere would report that.
|
||||||
|
|
||||||
WHY THERE IS NO `lesson_document()` BESIDE `rule_document()`
|
WHERE THE SHARP SHAPE LIVES
|
||||||
|
|
||||||
The step anticipated one. There isn't, and the difference is where the sharp
|
A snippet, which note #2485 measured as the only sharp record in the corpus (a
|
||||||
shape LIVES rather than whether it exists.
|
0.153 top-to-second gap against 0.010–0.023 for everything else), is sharp
|
||||||
|
because its document states its purpose twice: `name — when to use` as the
|
||||||
|
title, and again as the body's first line. A lesson follows the snippet.
|
||||||
|
|
||||||
A rule keeps its trigger in a column and its title is a plain name, so the
|
Until milestone 427 that `subject — trigger` title was also the STORED title,
|
||||||
`{title} — {trigger}` document has to be synthesised at embed time and exists
|
so every listing, menu and search row showed the trigger too — kilobytes of it.
|
||||||
nowhere else — that is what `rule_document` is for. A snippet, which note #2485
|
Now the stored title is the subject, and `embeddings.document_title` joins the
|
||||||
measured as the only sharp record in the corpus (a 0.153 top-to-second gap
|
trigger back from `data` at embed time. The embedded TEXT is what it always
|
||||||
against 0.010–0.023 for everything else), gets there the other way: its STORED
|
was, which is the property these guards pin: nothing re-embeds, and the floors
|
||||||
title is already the join and its stored body already opens with the trigger,
|
tuned against these vectors stay calibrated.
|
||||||
so the ordinary `title\\nbody` join is the sharp document. A lesson follows the
|
|
||||||
snippet, which is what step 1 decided and step 2 built.
|
|
||||||
|
|
||||||
The consequence worth stating: `chunk_document` is untouched, so
|
|
||||||
`CHUNKER_VERSION` does not move and nothing re-embeds. The step's "Re-embed"
|
|
||||||
section describes a change this design does not make.
|
|
||||||
|
|
||||||
These guards therefore assert the composed record, then assert that the generic
|
|
||||||
chunker turns it into the intended document — the two halves of the same claim.
|
|
||||||
No similarity number is asserted anywhere: a threshold pins the embedder's
|
No similarity number is asserted anywhere: a threshold pins the embedder's
|
||||||
behaviour rather than this code's, and breaks on a model change that is not a
|
behaviour rather than this code's, and breaks on a model change that is not a
|
||||||
regression.
|
regression.
|
||||||
@@ -34,18 +28,46 @@ regression.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from scribe.services import lessons as lessons_svc
|
from scribe.services import lessons as lessons_svc
|
||||||
from scribe.services.embeddings import chunk_document, embedding_text
|
from scribe.services.embeddings import chunk_document, document_title, embedding_text
|
||||||
|
|
||||||
TRIGGER = "a test fails on code you believe is correct"
|
TRIGGER = "a test fails on code you believe is correct"
|
||||||
SUBJECT = "Suspect the guard before the code"
|
SUBJECT = "Suspect the guard before the code"
|
||||||
INSIGHT = "Check whether the assertion still describes the property it was written for."
|
INSIGHT = "Check whether the assertion still describes the property it was written for."
|
||||||
|
|
||||||
|
|
||||||
|
def _embedded(what: str, trigger: str, insight: str) -> tuple[str, str]:
|
||||||
|
"""The (title, body) the write path EMBEDS a lesson as — built the way
|
||||||
|
`notes.embed_note` builds it, from what `create_lesson` stores."""
|
||||||
|
title, body = lessons_svc.lesson_document(what, trigger, insight)
|
||||||
|
data = lessons_svc.compose_data(what, trigger)
|
||||||
|
return document_title(title, lessons_svc.LESSON_NOTE_TYPE, data, body), body
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_stored_title_is_the_subject_alone():
|
||||||
|
"""Milestone 427. The trigger lives in `data` and the body's first line; the
|
||||||
|
title a listing shows is what the lesson is ABOUT."""
|
||||||
|
title, _ = lessons_svc.lesson_document(SUBJECT, TRIGGER, INSIGHT)
|
||||||
|
assert title == SUBJECT
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_embedded_document_is_the_one_it_always_was():
|
||||||
|
"""THE no-re-embed guard. The document text must be byte-identical to what
|
||||||
|
a lesson embedded as when its stored title carried the trigger — and an
|
||||||
|
un-migrated row, whose stored title still does, must come out the same
|
||||||
|
rather than with the trigger twice."""
|
||||||
|
legacy_title = f"{SUBJECT} — {TRIGGER}"
|
||||||
|
title, body = _embedded(SUBJECT, TRIGGER, INSIGHT)
|
||||||
|
assert title == legacy_title
|
||||||
|
|
||||||
|
data = lessons_svc.compose_data(SUBJECT, TRIGGER)
|
||||||
|
assert document_title(legacy_title, lessons_svc.LESSON_NOTE_TYPE, data, body) == legacy_title
|
||||||
|
|
||||||
|
|
||||||
def test_the_trigger_appears_twice_in_the_document():
|
def test_the_trigger_appears_twice_in_the_document():
|
||||||
"""THE guard. Purpose stated twice in a short document is the entire
|
"""Purpose stated twice in a short document is the entire measured cause of
|
||||||
measured cause of a snippet's sharpness, and it is the one property that
|
a snippet's sharpness, and it is the one property that distinguishes a
|
||||||
distinguishes a lesson's vector from a plain note's."""
|
lesson's vector from a plain note's."""
|
||||||
title, body = lessons_svc.lesson_document(SUBJECT, TRIGGER, INSIGHT)
|
title, body = _embedded(SUBJECT, TRIGGER, INSIGHT)
|
||||||
document = embedding_text(title, body)
|
document = embedding_text(title, body)
|
||||||
|
|
||||||
assert document.count(TRIGGER) == 2
|
assert document.count(TRIGGER) == 2
|
||||||
@@ -55,22 +77,28 @@ def test_the_trigger_appears_twice_in_the_document():
|
|||||||
|
|
||||||
|
|
||||||
def test_the_document_leads_with_when_it_applies():
|
def test_the_document_leads_with_when_it_applies():
|
||||||
"""The title is `{what} — {when}` and the body's FIRST line restates it, so
|
"""The embedded title is `{what} — {when}` and the body's FIRST line
|
||||||
the opening of the document is about the situation rather than the topic.
|
restates it, so the opening of the document is about the situation rather
|
||||||
A lesson buried behind a paragraph of narrative would rank on the
|
than the topic."""
|
||||||
narrative."""
|
title, body = _embedded(SUBJECT, TRIGGER, INSIGHT)
|
||||||
title, body = lessons_svc.lesson_document(SUBJECT, TRIGGER, INSIGHT)
|
|
||||||
|
|
||||||
assert title == f"{SUBJECT} — {TRIGGER}"
|
assert title == f"{SUBJECT} — {TRIGGER}"
|
||||||
assert body.splitlines()[0] == f"**When to apply:** {TRIGGER}"
|
assert body.splitlines()[0] == f"**When to apply:** {TRIGGER}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_trigger_reaches_the_document_even_without_the_mirror():
|
||||||
|
"""A row whose `data` lost its mirror still embeds sharply: the trigger is
|
||||||
|
read back from the body, the same fallback `lesson_trigger` has."""
|
||||||
|
title, body = lessons_svc.lesson_document(SUBJECT, TRIGGER, INSIGHT)
|
||||||
|
assert document_title(title, lessons_svc.LESSON_NOTE_TYPE, None, body) == (
|
||||||
|
f"{SUBJECT} — {TRIGGER}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_a_short_lesson_is_exactly_one_chunk():
|
def test_a_short_lesson_is_exactly_one_chunk():
|
||||||
"""`chunk_document`'s first contract line: a record inside the window
|
"""`chunk_document`'s first contract line: a record inside the window
|
||||||
yields one chunk identical to the historical `title\\nbody`. A lesson that
|
yields one chunk identical to the historical `title\\nbody`."""
|
||||||
split into several would spread the trigger's weight across vectors that
|
title, body = _embedded(SUBJECT, TRIGGER, INSIGHT)
|
||||||
each carry less of it."""
|
|
||||||
title, body = lessons_svc.lesson_document(SUBJECT, TRIGGER, INSIGHT)
|
|
||||||
chunks = chunk_document(title, body)
|
chunks = chunk_document(title, body)
|
||||||
|
|
||||||
assert len(chunks) == 1
|
assert len(chunks) == 1
|
||||||
@@ -78,26 +106,15 @@ def test_a_short_lesson_is_exactly_one_chunk():
|
|||||||
|
|
||||||
|
|
||||||
def test_a_long_lesson_keeps_the_trigger_on_every_chunk():
|
def test_a_long_lesson_keeps_the_trigger_on_every_chunk():
|
||||||
"""The narrative question, answered by the chunker rather than by holding
|
"""Every chunk is prefixed with the embedded title, which carries the
|
||||||
the story out of the record.
|
trigger — so a long story occupies its own vectors instead of averaging
|
||||||
|
itself into the trigger's, and each is still anchored to when it applies.
|
||||||
`rule_document` excludes a rule's `why` because long dated narrative made
|
|
||||||
sixteen dev-logs land on the centroid of "development". That finding
|
|
||||||
predates chunking (#280): a body over budget is now split, and EVERY chunk
|
|
||||||
is prefixed with the title — which for a lesson carries the trigger. So the
|
|
||||||
story occupies its own vectors instead of averaging itself into the
|
|
||||||
trigger's, and each of those vectors is still anchored to when the lesson
|
|
||||||
applies.
|
|
||||||
|
|
||||||
This is why the insight stays in the body where a reader can see it. Holding
|
|
||||||
it out would cost the reader the only part that explains the lesson, to buy
|
|
||||||
a sharpness the chunker already provides.
|
|
||||||
"""
|
"""
|
||||||
narrative = "\n\n".join(
|
narrative = "\n\n".join(
|
||||||
f"## Section {i}\n" + ("An unrelated sentence about deployment. " * 40)
|
f"## Section {i}\n" + ("An unrelated sentence about deployment. " * 40)
|
||||||
for i in range(6)
|
for i in range(6)
|
||||||
)
|
)
|
||||||
title, body = lessons_svc.lesson_document(SUBJECT, TRIGGER, narrative)
|
title, body = _embedded(SUBJECT, TRIGGER, narrative)
|
||||||
chunks = chunk_document(title, body)
|
chunks = chunk_document(title, body)
|
||||||
|
|
||||||
assert len(chunks) > 1, "the fixture must actually exceed the chunk budget"
|
assert len(chunks) > 1, "the fixture must actually exceed the chunk budget"
|
||||||
@@ -106,10 +123,8 @@ def test_a_long_lesson_keeps_the_trigger_on_every_chunk():
|
|||||||
|
|
||||||
def test_a_lesson_with_no_trigger_still_embeds():
|
def test_a_lesson_with_no_trigger_still_embeds():
|
||||||
"""Degrades to title + insight, the way a rule with no trigger does — less
|
"""Degrades to title + insight, the way a rule with no trigger does — less
|
||||||
sharply, and still findable. That is an argument for prompting hard for a
|
sharply, and still findable."""
|
||||||
trigger at write time, not for padding the document with whatever text is
|
title, body = _embedded(SUBJECT, "", INSIGHT)
|
||||||
to hand."""
|
|
||||||
title, body = lessons_svc.lesson_document(SUBJECT, "", INSIGHT)
|
|
||||||
|
|
||||||
assert title == SUBJECT
|
assert title == SUBJECT
|
||||||
assert body == INSIGHT
|
assert body == INSIGHT
|
||||||
@@ -119,8 +134,7 @@ def test_a_lesson_with_no_trigger_still_embeds():
|
|||||||
def test_the_composed_body_is_the_one_the_reader_is_parsed_back_from():
|
def test_the_composed_body_is_the_one_the_reader_is_parsed_back_from():
|
||||||
"""`compose_body` writes the trigger line and `lesson_trigger` reads it. A
|
"""`compose_body` writes the trigger line and `lesson_trigger` reads it. A
|
||||||
lesson whose mirror in `data` is missing still answers correctly, so the
|
lesson whose mirror in `data` is missing still answers correctly, so the
|
||||||
two must agree on the exact markdown — which is why neither is written by
|
two must agree on the exact markdown."""
|
||||||
hand at a call site."""
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
_, body = lessons_svc.lesson_document(SUBJECT, TRIGGER, INSIGHT)
|
_, body = lessons_svc.lesson_document(SUBJECT, TRIGGER, INSIGHT)
|
||||||
@@ -130,10 +144,8 @@ def test_the_composed_body_is_the_one_the_reader_is_parsed_back_from():
|
|||||||
|
|
||||||
|
|
||||||
def test_the_title_and_body_are_composed_by_one_call():
|
def test_the_title_and_body_are_composed_by_one_call():
|
||||||
"""`lesson_document` returns both halves so they cannot be built apart. A
|
"""`lesson_document` returns both halves so they cannot be built apart."""
|
||||||
title carrying the trigger over a body that does not would embed as an
|
|
||||||
ordinary note, and every listing would still look correct."""
|
|
||||||
assert lessons_svc.lesson_document(SUBJECT, TRIGGER, INSIGHT) == (
|
assert lessons_svc.lesson_document(SUBJECT, TRIGGER, INSIGHT) == (
|
||||||
lessons_svc.compose_title(SUBJECT, TRIGGER),
|
SUBJECT,
|
||||||
lessons_svc.compose_body(INSIGHT, TRIGGER),
|
lessons_svc.compose_body(INSIGHT, TRIGGER),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
from scribe.services import knowledge as knowledge_svc
|
from scribe.services import knowledge as knowledge_svc
|
||||||
from scribe.services import lessons as lessons_svc
|
from scribe.services import lessons as lessons_svc
|
||||||
from scribe.services import snippets as snippets_svc
|
|
||||||
from scribe.services.embeddings import trigger_title
|
from scribe.services.embeddings import trigger_title
|
||||||
|
|
||||||
|
|
||||||
@@ -76,15 +75,18 @@ def test_one_join_builds_every_trigger_title():
|
|||||||
expected = f"{subject} — {trigger}"
|
expected = f"{subject} — {trigger}"
|
||||||
|
|
||||||
assert trigger_title(subject, trigger) == expected
|
assert trigger_title(subject, trigger) == expected
|
||||||
assert lessons_svc.compose_title(subject, trigger) == expected
|
# The two note kinds reach it through the EMBEDDED title (milestone 427),
|
||||||
assert snippets_svc.compose_title(subject, trigger) == expected
|
# from their own mirror key — never a hand-rolled join.
|
||||||
|
from scribe.services.embeddings import document_title
|
||||||
|
assert document_title(subject, "lesson", {"when_to_apply": trigger}) == expected
|
||||||
|
assert document_title(subject, "snippet", {"when_to_use": trigger}) == expected
|
||||||
|
|
||||||
|
|
||||||
def test_a_subject_with_no_trigger_degrades_to_the_subject():
|
def test_a_subject_with_no_trigger_degrades_to_the_subject():
|
||||||
"""It still embeds, just less sharply — an argument for backfilling
|
"""It still embeds, just less sharply — an argument for backfilling
|
||||||
triggers, not for padding the title with whatever text is to hand."""
|
triggers, not for padding the title with whatever text is to hand."""
|
||||||
assert trigger_title("debounce", "") == "debounce"
|
assert trigger_title("debounce", "") == "debounce"
|
||||||
assert lessons_svc.compose_title(" debounce ") == "debounce"
|
assert lessons_svc.lesson_document(" debounce ")[0] == "debounce"
|
||||||
assert trigger_title("", "when it applies") == "when it applies"
|
assert trigger_title("", "when it applies") == "when it applies"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -214,13 +214,13 @@ def test_the_payload_reads_back_the_composed_fields():
|
|||||||
"""A caller that wrote `when_to_apply` reads `when_to_apply` back, not a
|
"""A caller that wrote `when_to_apply` reads `when_to_apply` back, not a
|
||||||
body it has to parse."""
|
body it has to parse."""
|
||||||
from tests.helpers import fake_lesson
|
from tests.helpers import fake_lesson
|
||||||
from scribe.services.lessons import compose_body, compose_title, lesson_to_dict
|
from scribe.services.lessons import compose_body, lesson_to_dict
|
||||||
|
|
||||||
what = "Read the job log before waiting longer"
|
what = "Read the job log before waiting longer"
|
||||||
trigger = "a CI run has sat in_progress longer than its suite takes"
|
trigger = "a CI run has sat in_progress longer than its suite takes"
|
||||||
note = fake_lesson(
|
note = fake_lesson(
|
||||||
id=7,
|
id=7,
|
||||||
title=compose_title(what, trigger),
|
title=what,
|
||||||
body=compose_body("The work is usually done.", trigger, [4181]),
|
body=compose_body("The work is usually done.", trigger, [4181]),
|
||||||
data={"what": what, "when_to_apply": trigger, "taught_by": [4181]},
|
data={"what": what, "when_to_apply": trigger, "taught_by": [4181]},
|
||||||
project_id=None,
|
project_id=None,
|
||||||
|
|||||||
@@ -117,7 +117,8 @@ async def test_the_duplicate_gate_runs_before_anything_is_created():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_the_gate_compares_the_composed_document_not_the_raw_fields():
|
async def test_the_gate_compares_the_composed_document_not_the_raw_fields():
|
||||||
"""What reaches the gate is the title and body a lesson will actually be
|
"""What reaches the gate is the title and body a lesson will actually be
|
||||||
stored as. Comparing `what` alone would miss that the trigger is half the
|
stored as, plus the `data` its EMBEDDED title is built from (milestone
|
||||||
|
427). Comparing `what` alone would miss that the trigger is half the
|
||||||
document, and would judge two lessons alike that rank nothing alike."""
|
document, and would judge two lessons alike that rank nothing alike."""
|
||||||
_user_id_ctx.set(7)
|
_user_id_ctx.set(7)
|
||||||
gate = AsyncMock(return_value=None)
|
gate = AsyncMock(return_value=None)
|
||||||
@@ -128,8 +129,9 @@ async def test_the_gate_compares_the_composed_document_not_the_raw_fields():
|
|||||||
await create_lesson(what=SUBJECT, when_to_apply=TRIGGER, insight="Look.")
|
await create_lesson(what=SUBJECT, when_to_apply=TRIGGER, insight="Look.")
|
||||||
|
|
||||||
title, body = gate.await_args.args[1], gate.await_args.args[2]
|
title, body = gate.await_args.args[1], gate.await_args.args[2]
|
||||||
assert title == f"{SUBJECT} — {TRIGGER}"
|
assert title == SUBJECT
|
||||||
assert body.startswith(f"**When to apply:** {TRIGGER}")
|
assert body.startswith(f"**When to apply:** {TRIGGER}")
|
||||||
|
assert gate.await_args.kwargs["data"]["when_to_apply"] == TRIGGER
|
||||||
assert gate.await_args.kwargs["note_type"] == "lesson"
|
assert gate.await_args.kwargs["note_type"] == "lesson"
|
||||||
|
|
||||||
|
|
||||||
@@ -157,9 +159,11 @@ def test_a_lesson_is_judged_at_the_trigger_dominated_bar():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_an_update_recomposes_both_halves_of_the_document():
|
async def test_an_update_recomposes_both_halves_of_the_document():
|
||||||
"""A new trigger has to reach the title AND the head of the body. Patching
|
"""A new trigger has to reach the mirror AND the head of the body — the two
|
||||||
one would leave a lesson that reads correctly and ranks on the old
|
places the embedded document reads it from (milestone 427). Patching one
|
||||||
situation — the failure mode with no symptom."""
|
would leave a lesson that reads correctly and ranks on the old situation —
|
||||||
|
the failure mode with no symptom. The title stays the subject, even when
|
||||||
|
the stored one was an un-migrated composed title."""
|
||||||
_user_id_ctx.set(7)
|
_user_id_ctx.set(7)
|
||||||
stored = _stub_note(
|
stored = _stub_note(
|
||||||
title=f"{SUBJECT} — {TRIGGER}",
|
title=f"{SUBJECT} — {TRIGGER}",
|
||||||
@@ -172,7 +176,7 @@ async def test_an_update_recomposes_both_halves_of_the_document():
|
|||||||
await lessons_svc.update_lesson(7, 1, when_to_apply="a guard goes red")
|
await lessons_svc.update_lesson(7, 1, when_to_apply="a guard goes red")
|
||||||
|
|
||||||
fields = updated.await_args.kwargs
|
fields = updated.await_args.kwargs
|
||||||
assert fields["title"] == f"{SUBJECT} — a guard goes red"
|
assert fields["title"] == SUBJECT
|
||||||
assert fields["body"].startswith("**When to apply:** a guard goes red")
|
assert fields["body"].startswith("**When to apply:** a guard goes red")
|
||||||
assert fields["data"]["when_to_apply"] == "a guard goes red"
|
assert fields["data"]["when_to_apply"] == "a guard goes red"
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,18 @@
|
|||||||
from scribe.services import snippets as s
|
from scribe.services import snippets as s
|
||||||
|
|
||||||
|
|
||||||
def test_compose_title_with_and_without_usage():
|
def test_the_embedded_title_joins_the_trigger_the_stored_one_does_not_carry():
|
||||||
assert s.compose_title("debounce", "rate-limit a callback") == "debounce — rate-limit a callback"
|
"""Milestone 427: stored title = name; the trigger joins it at embed time,
|
||||||
assert s.compose_title(" debounce ", "") == "debounce"
|
idempotently, so an old composed title comes out the same."""
|
||||||
assert s.compose_title("debounce") == "debounce"
|
from scribe.services.embeddings import document_title
|
||||||
|
|
||||||
|
data = {"name": "debounce", "when_to_use": "rate-limit a callback"}
|
||||||
|
assert document_title("debounce", "snippet", data) == "debounce — rate-limit a callback"
|
||||||
|
assert document_title("debounce — rate-limit a callback", "snippet", data) == (
|
||||||
|
"debounce — rate-limit a callback"
|
||||||
|
)
|
||||||
|
assert document_title("debounce", "snippet", {"name": "debounce"}) == "debounce"
|
||||||
|
assert document_title("a — note", "note", data) == "a — note"
|
||||||
|
|
||||||
|
|
||||||
def test_compose_tags_lowercases_language_and_dedups():
|
def test_compose_tags_lowercases_language_and_dedups():
|
||||||
@@ -32,7 +40,7 @@ def test_compose_body_bare_code_only():
|
|||||||
|
|
||||||
|
|
||||||
def test_parse_round_trips_a_composed_snippet():
|
def test_parse_round_trips_a_composed_snippet():
|
||||||
title = s.compose_title("useDebouncedRef", "debounce a reactive ref")
|
title = "useDebouncedRef"
|
||||||
body = s.compose_body(
|
body = s.compose_body(
|
||||||
code="const x = 1", language="ts", signature="useDebouncedRef(v, ms)",
|
code="const x = 1", language="ts", signature="useDebouncedRef(v, ms)",
|
||||||
when_to_use="debounce a reactive ref", repo="scribe",
|
when_to_use="debounce a reactive ref", repo="scribe",
|
||||||
@@ -241,9 +249,9 @@ def test_data_and_body_round_trip_to_the_same_fields():
|
|||||||
name, when, sig, lang = ("debounce", "rate-limit a callback",
|
name, when, sig, lang = ("debounce", "rate-limit a callback",
|
||||||
"debounce(fn, ms)", "ts")
|
"debounce(fn, ms)", "ts")
|
||||||
locs = [{"repo": "web", "path": "src/util.ts", "symbol": "debounce"}]
|
locs = [{"repo": "web", "path": "src/util.ts", "symbol": "debounce"}]
|
||||||
# compose_body takes no `name` — the name lives in the title — so the two
|
# compose_body takes no `name` — the name IS the title (milestone 427) — so
|
||||||
# serializers get their own argument lists rather than a shared spread.
|
# the two serializers get their own argument lists rather than a shared spread.
|
||||||
title = s.compose_title(name, when)
|
title = name
|
||||||
body = s.compose_body(code="const x = 1", language=lang, signature=sig,
|
body = s.compose_body(code="const x = 1", language=lang, signature=sig,
|
||||||
when_to_use=when, locations=locs, merged_from=[41, 42])
|
when_to_use=when, locations=locs, merged_from=[41, 42])
|
||||||
tags = s.compose_tags(lang)
|
tags = s.compose_tags(lang)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""A SessionStart that loads no project says why, and names the first move (#4366).
|
||||||
|
|
||||||
|
The fetch used to be `curl -f … || body=""`: a timeout, an HTTP error and a
|
||||||
|
refused key all produced one sentence, which ended by suggesting
|
||||||
|
`enter_project()` "as needed". A session that read it as optional started with
|
||||||
|
no recent milestones or open tasks, and so had no way to know which prior work
|
||||||
|
existed to look for. These pin the two halves of the fix on the path that needs
|
||||||
|
no server: the cause is named, and the fallback is a concrete first step.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tests.helpers import need_tools
|
||||||
|
|
||||||
|
HOOK = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "scribe_session_context.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def run_hook(tmp_path: Path, url: str, marker_project: int | None = None) -> str:
|
||||||
|
need_tools("bash", "curl", "awk")
|
||||||
|
if marker_project is not None:
|
||||||
|
(tmp_path / ".scribe").write_text(json.dumps({
|
||||||
|
"instance": url, "project_id": marker_project, "project": "P",
|
||||||
|
}))
|
||||||
|
env = {**os.environ, "SCRIBE_URL": url, "SCRIBE_TOKEN": "t",
|
||||||
|
"CLAUDE_PROJECT_DIR": str(tmp_path)}
|
||||||
|
r = subprocess.run(["bash", str(HOOK)], input=b'{"source":"startup"}',
|
||||||
|
capture_output=True, env=env, timeout=60)
|
||||||
|
assert r.returncode == 0, r.stderr.decode()
|
||||||
|
return json.loads(r.stdout)["hookSpecificOutput"]["additionalContext"]
|
||||||
|
|
||||||
|
|
||||||
|
# Port 9 (discard) on loopback: refused at once, so both tries fail fast.
|
||||||
|
DEAD = "http://127.0.0.1:9"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unreachable_instance_is_named_as_one(tmp_path):
|
||||||
|
out = run_hook(tmp_path, DEAD)
|
||||||
|
assert "could not be reached (curl exit 7, after a retry)" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_fallback_is_a_first_step_not_an_option(tmp_path):
|
||||||
|
out = run_hook(tmp_path, DEAD)
|
||||||
|
assert "as needed" not in out
|
||||||
|
assert "Start by finding this repo's project" in out
|
||||||
|
assert "before any other work" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_marker_names_the_exact_call(tmp_path):
|
||||||
|
out = run_hook(tmp_path, DEAD, marker_project=31)
|
||||||
|
assert "Start by calling `enter_project(31)`" in out
|
||||||
Reference in New Issue
Block a user