CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Successful in 34s
A snippet had no field for prose, so what a session learned about one went into when_to_use — the trigger joined onto every chunk it is embedded as. A sweep found write-ups of up to 3 KB there, headings and all. - notes: stored after the code under `## Notes`, parsed back from the body, carried by every path that rebuilds it (update, merge, un-merge). A snippet with no notes composes the body it always did. - create/update_snippet (MCP) take notes and return trigger_advice when when_to_use is long, headed or multi-paragraph. Advice, not a refusal. - Tool docs, the reusing-code skill and the editor hint describe the trigger as the situation and point the explanation at notes. - Editor gains a Notes field; the detail view renders it as markdown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
155 lines
6.0 KiB
Python
155 lines
6.0 KiB
Python
"""A snippet's notes — the explanation that had nowhere to go (#4378).
|
|
|
|
Before this field, a snippet had name, code, signature, locations and
|
|
`when_to_use`, and nothing for prose. What a session learned about a snippet
|
|
went into `when_to_use` — the trigger, joined onto the title of every chunk the
|
|
snippet is embedded as — so a multi-paragraph write-up there blurred the one
|
|
situation it is ranked on.
|
|
|
|
The rules pinned here, each with a way to rot silently:
|
|
|
|
- Notes live in the BODY, after the code, under `## Notes`, and read back
|
|
from it. A `## Notes` line inside the code is code, not the section.
|
|
- A snippet with no notes composes the body it always did, byte for byte —
|
|
no re-embed for the corpus that has none.
|
|
- Every path that rebuilds the body CARRIES the notes. `update_snippet`,
|
|
merge and un-merge all compose the body from scratch, so a field one of
|
|
them forgets is erased by any edit to something else.
|
|
- `trigger_advice` speaks for a write-up and stays quiet for a situation.
|
|
"""
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from tests.helpers import ensure_user
|
|
|
|
from scribe.services import snippets as s
|
|
|
|
CODE = "def helper():\n return 1"
|
|
NOTES = "Shaped this way because the caller owns the session.\n\nSuperseded #12."
|
|
|
|
|
|
# --- unit: the body convention -----------------------------------------------
|
|
|
|
def test_notes_follow_the_code_under_their_heading():
|
|
body = s.compose_body(code=CODE, language="python", when_to_use="a helper", notes=NOTES)
|
|
code_at = body.index("```python")
|
|
notes_at = body.index(s.NOTES_HEADING)
|
|
assert code_at < notes_at
|
|
assert body.rstrip().endswith("Superseded #12.")
|
|
|
|
|
|
def test_notes_round_trip_through_the_body():
|
|
body = s.compose_body(code=CODE, language="python", notes=NOTES)
|
|
got = s.parse_snippet_fields("helper", body, ["python", "snippet"])
|
|
assert got["notes"] == NOTES
|
|
assert got["code"] == CODE
|
|
|
|
|
|
def test_a_snippet_without_notes_composes_the_body_it_always_did():
|
|
"""No trailing section, no extra blank line: the corpus that has no notes
|
|
must embed exactly as before."""
|
|
body = s.compose_body(code=CODE, language="python", when_to_use="a helper")
|
|
assert body == "**When to use:** a helper\n\n```python\n" + CODE + "\n```\n"
|
|
assert s.parse_snippet_fields("helper", body)["notes"] == ""
|
|
|
|
|
|
def test_a_notes_heading_inside_the_code_is_code():
|
|
md = "# Title\n\n## Notes\n\nthis is markdown being recorded"
|
|
body = s.compose_body(code=md, language="markdown")
|
|
got = s.parse_snippet_fields("md_template", body)
|
|
assert got["notes"] == ""
|
|
assert got["code"] == md
|
|
|
|
|
|
def test_notes_after_code_that_itself_contains_the_heading():
|
|
md = "## Notes\ninside"
|
|
body = s.compose_body(code=md, language="markdown", notes="the real notes")
|
|
got = s.parse_snippet_fields("md_template", body)
|
|
assert got["notes"] == "the real notes"
|
|
assert got["code"] == md
|
|
|
|
|
|
def test_long_notes_are_chunked_apart_from_the_code():
|
|
"""The heading is the chunker's split point, so a long explanation gets
|
|
vectors of its own instead of averaging into the code's."""
|
|
from scribe.services.embeddings import chunk_document
|
|
|
|
long_notes = "\n\n".join(["An explanatory paragraph about the history. " * 12] * 4)
|
|
body = s.compose_body(code=CODE, language="python", when_to_use="a helper",
|
|
notes=long_notes)
|
|
chunks = chunk_document("helper — a helper", body)
|
|
assert len(chunks) > 1
|
|
assert "```python" in chunks[0]
|
|
assert "```python" not in chunks[-1]
|
|
|
|
|
|
# --- unit: the advice ---------------------------------------------------------
|
|
|
|
def test_a_situation_draws_no_advice():
|
|
assert s.trigger_advice("Debouncing a reactive input before it fetches.") is None
|
|
assert s.trigger_advice("") is None
|
|
assert s.trigger_advice(None) is None
|
|
|
|
|
|
def test_a_long_trigger_is_advised_toward_notes():
|
|
advice = s.trigger_advice("x" * (s.TRIGGER_ADVISE_CHARS + 1))
|
|
assert advice and "notes" in advice
|
|
assert f"{s.TRIGGER_ADVISE_CHARS + 1} characters" in advice
|
|
|
|
|
|
def test_a_headed_or_multi_paragraph_trigger_is_advised_even_when_short():
|
|
assert "headings" in s.trigger_advice("When adding a record.\n\n## Why\nbecause")
|
|
assert "paragraphs" in s.trigger_advice("When adding a record.\n\nAlso, history.")
|
|
|
|
|
|
# --- integration: every body rebuild carries the notes ------------------------
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def user_id(_dispose_engine):
|
|
from scribe.models import async_session
|
|
|
|
async with async_session() as session:
|
|
uid = (await ensure_user(session, "snippet_notes_itest")).id
|
|
await session.commit()
|
|
return uid
|
|
|
|
|
|
async def _fields(uid, note_id):
|
|
return s.snippet_fields(await s.get_snippet(uid, note_id))
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_an_edit_to_another_field_keeps_the_notes(user_id):
|
|
note = await s.create_snippet(
|
|
user_id, name="notes_keep", code=CODE, language="python",
|
|
when_to_use="a helper", notes=NOTES,
|
|
)
|
|
assert (await _fields(user_id, note.id))["notes"] == NOTES
|
|
|
|
await s.update_snippet(user_id, note.id, when_to_use="a sharper situation")
|
|
got = await _fields(user_id, note.id)
|
|
assert got["notes"] == NOTES
|
|
assert got["when_to_use"] == "a sharper situation"
|
|
|
|
# And an empty string clears them, like every other field.
|
|
await s.update_snippet(user_id, note.id, notes="")
|
|
assert (await _fields(user_id, note.id))["notes"] == ""
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_a_merge_keeps_the_survivors_notes(user_id):
|
|
survivor = await s.create_snippet(
|
|
user_id, name="notes_merge_a", code=CODE, language="python",
|
|
repo="R", path="a.py", symbol="helper", notes=NOTES,
|
|
)
|
|
source = await s.create_snippet(
|
|
user_id, name="notes_merge_b", code=CODE + " # variant", language="python",
|
|
repo="R", path="b.py", symbol="helper",
|
|
)
|
|
await s.merge_snippets(user_id, survivor.id, [source.id])
|
|
assert (await _fields(user_id, survivor.id))["notes"] == NOTES
|
|
|
|
await s.unmerge_snippet(user_id, survivor.id, source.id)
|
|
assert (await _fields(user_id, survivor.id))["notes"] == NOTES
|