fix(search): show the passage that matched, not the opening of the body (#4243)
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m5s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 46s

Raised by the operator: are we limiting what comes back by character count,
and how do we verify the pertinent part is the part displayed?

We were not. mcp/tools/search.py sent (note.body or "")[:240] — a head cut,
with no marker that anything had been removed, so a 240-character preview of
a 4000-character record was indistinguishable from a complete short one.

The opening is the wrong span. The match is semantic and per chunk, and
semantic_search_notes collapses to best-chunk-per-note — its own comment at
the collapse says "the first appearance of a note is its best chunk". So the
system identified the passage that earned the hit and then discarded it:
select(Note, distance) kept no chunk column. A record could rank first on its
sixth paragraph, be previewed by its first, and be judged irrelevant on a
span the search had already scored lower. That biases against long records,
and it is self-concealing — the caller who does not open it never learns the
preview was misleading.

  - embeddings: chunk_index/chunk_text ride along in the select, and the
    collapse records the winner in report["best_chunk"]. Carried in `report`,
    NOT by widening the return tuple: ten callers unpack (score, note) at
    ~18 sites and nothing would catch the misses (lesson #4207). `report` is
    the side-channel this function already uses for best_available_score.
  - search(): excerpt / excerpt_is / body_length, and read_full when there is
    more. A caller that cannot tell a matched passage from a document opening
    cannot judge whether to look deeper, which is the only decision the field
    supports.

elide() moves to services/text.py so both callers share one copy, and it
keeps BOTH ends with a stated gap — it is the fallback for when nothing
identifies a better span than "all of it", not the goal.

Also fixes a guard that produced a false failure on the previous commit:
test_pull_telemetry checked `"project_id: int = 0" in body.split("\n")[0]`,
which sees only the first line, so wrapping get_task's signature over four
lines made it report a function that does take the project as one that does
not. Parsed with ast now, and proven to still reject an absent or
wrongly-typed parameter rather than being appeased by reflowing the code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-21 08:50:16 -04:00
co-authored by Claude Opus 5
parent 4f2977b848
commit fdc07f2a2b
7 changed files with 245 additions and 50 deletions
+65 -2
View File
@@ -11,6 +11,7 @@ import time
from scribe.mcp._context import current_user_id from scribe.mcp._context import current_user_id
from scribe.services.access import owner_names_for from scribe.services.access import owner_names_for
from scribe.services.text import elide
from scribe.services.embeddings import ( from scribe.services.embeddings import (
DEFAULT_SIMILARITY_THRESHOLD, semantic_search_milestones, semantic_search_notes, DEFAULT_SIMILARITY_THRESHOLD, semantic_search_milestones, semantic_search_notes,
semantic_search_rules, semantic_search_rules,
@@ -102,6 +103,55 @@ async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> d
} }
# A matched chunk is at most _CHUNK_CHAR_BUDGET (1400) characters, and it is
# the evidence the ranking was built on — so it is worth more room than the 240
# characters of document opening this used to send. Elision inside a chunk is
# far less lossy than a head cut of a whole record: the region is already the
# right one.
_EXCERPT_CHARS = 1000
def result_excerpt(note, chunk: dict | None) -> dict:
"""The part of a record a caller judges "should I open this?" on.
This used to be `(note.body or "")[:240]` — the document's opening, with
no marker that anything had been cut, so a 240-character preview of a
4000-character record was indistinguishable from a complete short one.
The opening is the wrong span. The match was semantic and per-chunk, and
`semantic_search_notes` collapses to best-chunk-per-note — so the system
already knows which passage earned the hit and used to discard it. A
record could rank first on its sixth paragraph and be previewed by its
first, which the search had already judged less relevant, and the caller
would decide from that and never know (#4243).
So: show the matched passage when there is one, the body when it fits, and
in either case SAY which of the two this is. A caller that cannot tell an
excerpt from a whole record cannot tell whether looking deeper is worth it,
which is the only decision this field supports.
"""
body = note.body or ""
matched = (chunk or {}).get("text") or ""
out: dict = {"body_length": len(body)}
if matched.strip():
text, cut = elide(matched.strip(), _EXCERPT_CHARS)
out["excerpt"] = text
out["excerpt_is"] = "matched_passage"
if (chunk or {}).get("index") is not None:
out["chunk_index"] = int(chunk["index"])
else:
# No stored chunk — an un-embedded record, or a caller that passed no
# report. Fall back to the opening, and name it as the opening rather
# than letting it pass for the relevant part.
text, cut = elide(body, _EXCERPT_CHARS)
out["excerpt"] = text
out["excerpt_is"] = "body_opening"
if cut or (out["excerpt_is"] == "matched_passage" and len(matched) < len(body)):
out["read_full"] = "get_note / get_task by id for the whole record."
return out
async def search( async def search(
q: str, q: str,
content_type: str = "all", content_type: str = "all",
@@ -150,9 +200,21 @@ async def search(
list_system_records gives the same slice unranked. list_system_records gives the same slice unranked.
Returns: Returns:
{"results": [{"id", "title", "body", "is_task", "tags", "similarity"}], {"results": [{"id", "title", "excerpt", "excerpt_is", "body_length",
"is_task", "tags", "similarity"}],
"total": int} "total": int}
`excerpt` is a SPAN of the record, not the record. `excerpt_is` says
which span: "matched_passage" is the chunk that actually earned the
hit — the evidence the ranking was built on, and the right thing to
judge relevance from. "body_opening" is a fallback for a record with
no stored chunk, and is only the beginning of the text, which may say
nothing about why it matched. `body_length` is the whole record's
size, so a long record previewed by a short span is visible as one;
`read_full` appears when there is more, and opening the id by
get_note / get_task is how you get it. Judge from the passage, not
from the fact that a preview looked thin.
A result marked `shared: true` with an `owner` belongs to another user — A result marked `shared: true` with an `owner` belongs to another user —
that person's suggestion, not the operator's own record or settled practice. that person's suggestion, not the operator's own record or settled practice.
Weigh it on its merits and say whose it is when you use it. Weigh it on its merits and say whose it is when you use it.
@@ -195,12 +257,13 @@ async def search(
owners = await owner_names_for( owners = await owner_names_for(
{int(note.user_id) for _s, note in raw if note.user_id != uid} {int(note.user_id) for _s, note in raw if note.user_id != uid}
) )
chunks = report.get("best_chunk") or {}
return { return {
"results": [ "results": [
{ {
"id": note.id, "id": note.id,
"title": note.title, "title": note.title,
"body": (note.body or "")[:240], **result_excerpt(note, chunks.get(int(note.id))),
"is_task": bool(note.is_task), "is_task": bool(note.is_task),
"tags": list(note.tags or []), "tags": list(note.tags or []),
"similarity": float(score), "similarity": float(score),
+1 -25
View File
@@ -45,6 +45,7 @@ from scribe.services import task_logs as task_logs_svc
from scribe.services import trash as trash_svc from scribe.services import trash as trash_svc
from scribe.services.note_usage import record_pulled from scribe.services.note_usage import record_pulled
from scribe.services.record_refs import refuse_guessed_ids from scribe.services.record_refs import refuse_guessed_ids
from scribe.services.text import elide
# A work log entry is prose, often long — the discipline asks for what was # A work log entry is prose, often long — the discipline asks for what was
@@ -64,31 +65,6 @@ _WORK_LOG_ADVICE = (
) )
def elide(text: str, budget: int) -> tuple[str, bool]:
"""Cut to `budget` characters from the MIDDLE, keeping both ends.
A head-only cut — `text[:800]` — decides what a reader sees by character
position, which is uncorrelated with what matters. Prose does not put its
conclusion first: a log entry that opens with what was tried and closes
with "so this shipped in 04775c3" loses exactly the sentence that answers
the question, and the reader cannot tell, because a truncation marker says
that something was removed and never whether it mattered.
So keep the opening (what this entry is about) AND the closing (where it
landed), and say in between how much went. Two thirds to the head because
that is where the subject is established; the tail needs less to carry a
conclusion. `budget` of 0 means no cut.
"""
if budget <= 0 or len(text) <= budget:
return text, False
head_len = max(1, budget * 2 // 3)
tail_len = max(1, budget - head_len)
omitted = len(text) - head_len - tail_len
head = text[:head_len].rstrip()
tail = text[-tail_len:].lstrip()
return f"{head}\n\n[… {omitted} characters omitted …]\n\n{tail}", True
def work_log_payload( def work_log_payload(
logs: list, total: int, chars: int, latest_chars: int = _WORK_LOG_LATEST_CHARS logs: list, total: int, chars: int, latest_chars: int = _WORK_LOG_LATEST_CHARS
) -> dict: ) -> dict:
+35 -4
View File
@@ -686,7 +686,16 @@ async def semantic_search_notes(
# to the note's owner, so filtering it would pin every scope to "own" # to the note's owner, so filtering it would pin every scope to "own"
# and leave shared records unreachable by meaning. # and leave shared records unreachable by meaning.
stmt = ( stmt = (
select(Note, distance.label("distance")) # chunk_index/chunk_text ride along so the collapse below can
# say WHICH passage matched. Without them the caller is left
# previewing the head of the body — a span this query has
# already determined is not why the record ranked (#4243).
select(
Note,
distance.label("distance"),
NoteEmbedding.chunk_index,
NoteEmbedding.chunk_text,
)
.select_from(NoteEmbedding) .select_from(NoteEmbedding)
.join(Note, NoteEmbedding.note_id == Note.id) .join(Note, NoteEmbedding.note_id == Note.id)
.where( .where(
@@ -774,11 +783,23 @@ async def semantic_search_notes(
# Recover similarity (1 - distance); order stays highest-first. # Recover similarity (1 - distance); order stays highest-first.
scored: list[tuple[float, Note]] = [] scored: list[tuple[float, Note]] = []
seen: set[int] = set() seen: set[int] = set()
for note, dist in rows: # The winning row IS the best chunk, by the ordering above — so this is the
# one place that knows which passage earned the hit. Kept beside the score
# rather than returned with it: the return type is list[tuple[float, Note]]
# and ten callers unpack it at ~18 sites, so widening the tuple would be an
# interface change to every one of them with nothing to catch the misses
# (lesson #4207). `report` is the side-channel this function already uses
# for best_available_score.
best_chunk: dict[int, dict] = {}
for note, dist, chunk_index, chunk_text in rows:
if int(note.id) in seen: if int(note.id) in seen:
continue continue
seen.add(int(note.id)) seen.add(int(note.id))
scored.append((1.0 - float(dist), note)) scored.append((1.0 - float(dist), note))
best_chunk[int(note.id)] = {
"index": int(chunk_index),
"text": chunk_text or "",
}
# The best score anything reached, bar or no bar. Recorded BEFORE the # The best score anything reached, bar or no bar. Recorded BEFORE the
# filter because a call that returns nothing is exactly when it matters. # filter because a call that returns nothing is exactly when it matters.
if report is not None: if report is not None:
@@ -797,8 +818,18 @@ async def semantic_search_notes(
report["best_available_id"] = int(best[1].id) if best else None report["best_available_id"] = int(best[1].id) if best else None
scored = [pair for pair in scored if pair[0] >= threshold] scored = [pair for pair in scored if pair[0] >= threshold]
if not demote_superseded: if not demote_superseded:
return scored[:limit] final = scored[:limit]
return await _apply_supersession_penalty(scored, limit) else:
final = await _apply_supersession_penalty(scored, limit)
# Only for what actually came back, so a caller can key straight off the
# results without carrying chunks for records it never saw.
if report is not None:
report["best_chunk"] = {
int(n.id): best_chunk[int(n.id)]
for _s, n in final
if int(n.id) in best_chunk
}
return final
async def backfill_note_embeddings() -> None: async def backfill_note_embeddings() -> None:
+38
View File
@@ -0,0 +1,38 @@
"""Text shortening for surfaces that cannot show a record whole.
One function, in one place, because both doors shorten and two copies would
drift — and because the reasoning below is the part that matters and should
not have to be re-derived at each call site.
"""
def elide(text: str, budget: int) -> tuple[str, bool]:
"""Cut to `budget` characters from the MIDDLE, keeping both ends.
A head-only cut — `text[:800]` — decides what a reader sees by character
position, which is uncorrelated with what matters. Prose does not put its
conclusion first: a passage that opens with what was attempted and closes
with "so this shipped in 04775c3" loses exactly the sentence that answers
the question. Worse, the reader cannot tell: a truncation marker says that
something was removed, never whether it mattered, so the decision "should
I look deeper?" gets made on evidence selected by length.
So keep the opening (what this is about) AND the closing (where it landed),
and state in between how much went. Two thirds to the head because that is
where the subject is established; a conclusion needs less room to carry.
Returns (text, was_cut). A `budget` of 0 or less means no cut.
This is the fallback, not the goal. Where the system knows WHICH span of a
record is the relevant one — a semantic search knows exactly that, and
stores it (#4243) — show that span and say so. Reach for this only when
nothing identifies a better part than "all of it".
"""
if budget <= 0 or len(text) <= budget:
return text, False
head_len = max(1, budget * 2 // 3)
tail_len = max(1, budget - head_len)
omitted = len(text) - head_len - tail_len
head = text[:head_len].rstrip()
tail = text[-tail_len:].lstrip()
return f"{head}\n\n[… {omitted} characters omitted …]\n\n{tail}", True
+70 -5
View File
@@ -39,23 +39,88 @@ async def test_fable_search_returns_repackaged_results():
r = out["results"][0] r = out["results"][0]
assert r["id"] == 1 assert r["id"] == 1
assert r["title"] == "kafka rebalance" assert r["title"] == "kafka rebalance"
assert r["body"] == "HPA details" # The whole body, because it fits — and named as the opening, not as the
# passage that matched, since this call patched the search and so carries
# no chunk.
assert r["excerpt"] == "HPA details"
assert r["excerpt_is"] == "body_opening"
assert r["body_length"] == len("HPA details")
assert r["is_task"] is False assert r["is_task"] is False
assert r["tags"] == ["ops"] assert r["tags"] == ["ops"]
assert r["similarity"] == pytest.approx(0.93) assert r["similarity"] == pytest.approx(0.93)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fable_search_body_is_truncated_to_240_chars(): async def test_search_shows_the_passage_that_matched_not_the_opening():
"""The point of #4243. A record can rank on its sixth paragraph; showing
its first is showing the caller a span the search already judged less
relevant, and letting them decide from it."""
_user_id_ctx.set(7) _user_id_ctx.set(7)
long_body = "x" * 500 body = "Chapter one, about nothing. " * 40 + " THE ANSWER IS 04775c3."
fake = fake_note(id=1, title="t", body=long_body) fake = fake_note(id=1, title="t", body=body)
async def _search(*a, **kw):
kw["report"]["best_chunk"] = {
1: {"index": 3, "text": "THE ANSWER IS 04775c3."}
}
return [(0.5, fake)]
with patch("scribe.mcp.tools.search.semantic_search_notes", _search):
out = await search(q="answer")
r = out["results"][0]
assert r["excerpt"] == "THE ANSWER IS 04775c3."
assert r["excerpt_is"] == "matched_passage"
assert r["chunk_index"] == 3
# And the caller is told there is more record behind the passage.
assert r["body_length"] == len(body)
assert "read_full" in r
@pytest.mark.asyncio
async def test_a_result_says_whether_its_excerpt_is_the_match_or_the_opening():
"""A caller that cannot tell the two apart cannot judge whether looking
deeper is worth it, which is the only decision this field supports."""
_user_id_ctx.set(7)
fake = fake_note(id=1, title="t", body="short body")
with patch( with patch(
"scribe.mcp.tools.search.semantic_search_notes", "scribe.mcp.tools.search.semantic_search_notes",
AsyncMock(return_value=[(0.5, fake)]), AsyncMock(return_value=[(0.5, fake)]),
): ):
out = await search(q="x") out = await search(q="x")
assert len(out["results"][0]["body"]) == 240 assert out["results"][0]["excerpt_is"] == "body_opening"
@pytest.mark.asyncio
async def test_a_long_excerpt_keeps_both_ends_and_says_how_much_went():
"""The fallback is still an elision, and an elision that drops the tail
drops wherever the conclusion was."""
_user_id_ctx.set(7)
body = "OPENING. " + ("m" * 3000) + " CLOSING."
fake = fake_note(id=1, title="t", body=body)
with patch(
"scribe.mcp.tools.search.semantic_search_notes",
AsyncMock(return_value=[(0.5, fake)]),
):
out = await search(q="x")
excerpt = out["results"][0]["excerpt"]
assert excerpt.startswith("OPENING.")
assert excerpt.rstrip().endswith("CLOSING.")
assert "characters omitted" in excerpt
assert out["results"][0]["body_length"] == len(body)
@pytest.mark.asyncio
async def test_a_short_record_arrives_whole_and_unmarked():
"""Fragmenting a 200-character note serves nobody."""
_user_id_ctx.set(7)
fake = fake_note(id=1, title="t", body="all of it")
with patch(
"scribe.mcp.tools.search.semantic_search_notes",
AsyncMock(return_value=[(0.5, fake)]),
):
out = await search(q="x")
assert out["results"][0]["excerpt"] == "all of it"
assert "read_full" not in out["results"][0]
@pytest.mark.asyncio @pytest.mark.asyncio
+22 -1
View File
@@ -224,13 +224,34 @@ def _pulling_getters():
yield module, name, body yield module, name, body
def _takes_reading_project(body: str) -> bool:
"""Does this function declare `project_id: int = 0`?
Parsed, not string-matched against the first line. The first version read
`body.split("\n")[0]`, which sees only as far as the first newline — so
adding a parameter to `get_task` wrapped its signature over four lines and
the guard reported a function that DOES take the project as one that does
not. A guard that fails on formatting is a guard that gets appeased by
reflowing the code it was meant to check.
"""
node = ast.parse(body).body[0]
args = node.args
params = list(args.posonlyargs) + list(args.args) + list(args.kwonlyargs)
for arg in params:
if arg.arg != "project_id":
continue
ann = getattr(arg, "annotation", None)
return isinstance(ann, ast.Name) and ann.id == "int"
return False
def test_every_getter_that_pulls_takes_the_reading_project(): def test_every_getter_that_pulls_takes_the_reading_project():
"""Asserted on structure (rule 167): a behavioural test cannot see a """Asserted on structure (rule 167): a behavioural test cannot see a
parameter that was never threaded through.""" parameter that was never threaded through."""
missing = [ missing = [
f"{module}.{name}" f"{module}.{name}"
for module, name, body in _pulling_getters() for module, name, body in _pulling_getters()
if "project_id: int = 0" not in body.split("\n")[0] if not _takes_reading_project(body)
] ]
assert not missing, ( assert not missing, (
f"these getters record a pull but cannot say where the reader was: " f"these getters record a pull but cannot say where the reader was: "
+14 -13
View File
@@ -20,6 +20,15 @@ from scribe.mcp.tools.tasks import (
elide, get_task, list_tasks, work_log_payload, elide, get_task, list_tasks, work_log_payload,
) )
from scribe.services.notes import brief_row from scribe.services.notes import brief_row
# Bound at IMPORT time, which is before any fixture runs — so these names
# keep pointing at the real implementations even though conftest's autouse
# _no_task_log_arm replaces the module attributes for every test. Reaching
# them as `task_logs.logs_for_task` would get the stub and test nothing.
from scribe.services.task_logs import (
count_logs_for_task,
log_counts_for_tasks,
logs_for_task,
)
from tests.helpers import fake_task, make_mock_session from tests.helpers import fake_task, make_mock_session
pytestmark = pytest.mark.usefixtures("_bind_user") pytestmark = pytest.mark.usefixtures("_bind_user")
@@ -304,8 +313,6 @@ async def test_logs_for_task_is_not_filtered_by_who_wrote_the_entry():
"""`list_logs` filters TaskLog.user_id == user_id, which hands a shared """`list_logs` filters TaskLog.user_id == user_id, which hands a shared
collaborator an empty list that reads as "no work has been done". The work collaborator an empty list that reads as "no work has been done". The work
log belongs to the task.""" log belongs to the task."""
from scribe.services import task_logs as svc
session = make_mock_session() session = make_mock_session()
session.execute = AsyncMock(return_value=MagicMock( session.execute = AsyncMock(return_value=MagicMock(
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))) scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
@@ -314,7 +321,7 @@ async def test_logs_for_task_is_not_filtered_by_who_wrote_the_entry():
AsyncMock(return_value=True)), \ AsyncMock(return_value=True)), \
patch("scribe.services.task_logs.async_session", patch("scribe.services.task_logs.async_session",
MagicMock(return_value=session)): MagicMock(return_value=session)):
await svc.logs_for_task(7, 42) await logs_for_task(7, 42)
stmt = str(session.execute.await_args.args[0]) stmt = str(session.execute.await_args.args[0])
assert "task_logs.task_id" in stmt assert "task_logs.task_id" in stmt
assert "task_logs.user_id" not in stmt assert "task_logs.user_id" not in stmt
@@ -324,26 +331,22 @@ async def test_logs_for_task_is_not_filtered_by_who_wrote_the_entry():
async def test_logs_for_task_refuses_a_task_the_caller_cannot_read(): async def test_logs_for_task_refuses_a_task_the_caller_cannot_read():
"""Unscoped would have been the mirror-image hole: rule #78 is about """Unscoped would have been the mirror-image hole: rule #78 is about
routing the question through the access layer, in both directions.""" routing the question through the access layer, in both directions."""
from scribe.services import task_logs as svc
opened = MagicMock() opened = MagicMock()
with patch("scribe.services.task_logs.can_read_note", with patch("scribe.services.task_logs.can_read_note",
AsyncMock(return_value=False)), \ AsyncMock(return_value=False)), \
patch("scribe.services.task_logs.async_session", opened): patch("scribe.services.task_logs.async_session", opened):
out = await svc.logs_for_task(7, 42) out = await logs_for_task(7, 42)
assert out == [] assert out == []
assert opened.call_count == 0 assert opened.call_count == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_count_for_an_unreadable_task_is_zero_not_a_leak(): async def test_count_for_an_unreadable_task_is_zero_not_a_leak():
from scribe.services import task_logs as svc
opened = MagicMock() opened = MagicMock()
with patch("scribe.services.task_logs.can_read_note", with patch("scribe.services.task_logs.can_read_note",
AsyncMock(return_value=False)), \ AsyncMock(return_value=False)), \
patch("scribe.services.task_logs.async_session", opened): patch("scribe.services.task_logs.async_session", opened):
assert await svc.count_logs_for_task(7, 42) == 0 assert await count_logs_for_task(7, 42) == 0
assert opened.call_count == 0 assert opened.call_count == 0
@@ -351,15 +354,13 @@ async def test_count_for_an_unreadable_task_is_zero_not_a_leak():
async def test_log_counts_for_tasks_scopes_by_readability_in_the_same_query(): async def test_log_counts_for_tasks_scopes_by_readability_in_the_same_query():
"""A per-row can_read_note would be the N+1 this function exists to avoid, """A per-row can_read_note would be the N+1 this function exists to avoid,
so the permission goes in as set membership instead.""" so the permission goes in as set membership instead."""
from scribe.services import task_logs as svc
session = make_mock_session() session = make_mock_session()
session.execute = AsyncMock(return_value=MagicMock( session.execute = AsyncMock(return_value=MagicMock(
all=MagicMock(return_value=[(1, 3)]) all=MagicMock(return_value=[(1, 3)])
)) ))
with patch("scribe.services.task_logs.async_session", with patch("scribe.services.task_logs.async_session",
MagicMock(return_value=session)): MagicMock(return_value=session)):
out = await svc.log_counts_for_tasks(7, [1, 2]) out = await log_counts_for_tasks(7, [1, 2])
assert out == {1: 3} assert out == {1: 3}
stmt = str(session.execute.await_args.args[0]) stmt = str(session.execute.await_args.args[0])
assert "notes" in stmt.lower() assert "notes" in stmt.lower()
@@ -371,5 +372,5 @@ async def test_no_ids_asks_the_database_nothing():
opened = MagicMock() opened = MagicMock()
with patch("scribe.services.task_logs.async_session", opened): with patch("scribe.services.task_logs.async_session", opened):
assert await svc.log_counts_for_tasks(7, []) == {} assert await log_counts_for_tasks(7, []) == {}
assert opened.call_count == 0 assert opened.call_count == 0