Files
FabledScribe/tests/test_task_document_shape.py
T
bvandeusenandClaude Opus 5.5 4a93c8b262
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Failing after 24s
fix(task-logs): a collaborator who may write a task may log on it (rule 78)
create_log filtered on Note.user_id == user_id, a bare owner check, so a
collaborator with write access to a shared task was told it did not exist
— and, since a log now stamps the claim, could never be seen working it.
It now asks can_write_note. Editing and deleting a log still require its
author, which is authorship rather than access.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 06:48:01 -04:00

203 lines
8.6 KiB
Python

"""task_document — the document a TASK is embedded as, work logs included (#4251).
A work log is the richest record Scribe holds of *why* something is the way it
is: prose written during the work, saying what was tried and ruled out. #4241
made it readable. It was still not findable, so "has anyone tried this
approach?" — the question a log answers — could not reach one, and #4208 was
rebuilt in this very session because its logs were unreachable.
These tests pin the shape and the two properties the decision rested on: a task
without logs embeds EXACTLY as it always did, and a task with them stays as
discriminative as it was, because each log is its own title-anchored chunk
rather than prose averaged into one vector.
"""
import datetime
from scribe.services.embeddings import (
WORK_LOG_HEADING,
chunk_document,
embedding_text,
task_document,
)
D1 = datetime.datetime(2026, 9, 20, 10, 0)
D2 = datetime.datetime(2026, 9, 21, 11, 0)
# --- the identity half: a task with no logs is untouched ---------------------
def test_a_task_with_no_logs_embeds_exactly_as_it_did_before():
"""Most notes are not tasks and most tasks carry no log. Their vectors are
the corpus this instance's thresholds were measured against, and changing
them for nothing would move every tuned number underneath itself (#4225)."""
assert task_document("A title", "A body") == ("A title", "A body")
assert task_document("A title", "A body", []) == ("A title", "A body")
assert task_document("T", None) == ("T", None)
def test_an_entry_with_no_content_is_skipped_rather_than_emitted_empty():
"""A bare heading is a vector containing nothing but the task's title — it
would compete with the task's real chunk and say nothing."""
_t, body = task_document("T", "prose", [(D1, " "), (D2, None), (D1, "real")])
assert body.count(WORK_LOG_HEADING) == 1
assert "real" in body
def test_a_task_that_is_only_logs_still_yields_a_document():
"""A task opened with a title and filled in entirely through its log — the
shape every step of a milestone starts as."""
title, body = task_document("T", "", [(D1, "the only content")])
assert title == "T"
assert body.startswith(WORK_LOG_HEADING)
assert "the only content" in body
# --- the shape: the logs are sections, oldest first --------------------------
def test_logs_are_appended_as_dated_sections_after_the_tasks_own_prose():
title, body = task_document(
"Fix the thing", "The task prose.",
[(D1, "Tried X, ruled out."), (D2, "Y worked.")],
)
assert title == "Fix the thing"
assert body.index("The task prose.") < body.index("Tried X") < body.index("Y worked.")
assert f"{WORK_LOG_HEADING} — 2026-09-20" in body
assert f"{WORK_LOG_HEADING} — 2026-09-21" in body
def test_an_entry_with_no_timestamp_still_gets_its_own_section():
"""Degrades to an undated heading rather than dropping the entry or
crashing — a restored row or a hand-built one is not a reason to lose the
richest prose in the record."""
_t, body = task_document("T", "p", [(None, "content from nowhere")])
assert WORK_LOG_HEADING in body
assert "content from nowhere" in body
# --- why folding them in is safe: chunking, not averaging --------------------
def test_each_log_becomes_its_own_title_anchored_chunk():
"""THE decision this build turns on. The objection to putting logs in the
task's document is that a long log drowns a short title — true before #280,
when one vector per record meant a 2,000-word log averaged the task's
subject away and everything past ~400 words was truncated unread. Chunking
answers both: separate vectors, each carrying the title as its anchor."""
def _entry(subject: str) -> str:
para = " ".join([f"This log entry discusses the {subject} in detail."] * 8)
return "\n\n".join(f"{para} (p{i})" for i in range(6))
title, body = task_document(
"Short task title", "Short body.",
[(D1, _entry("approach")), (D2, _entry("alternative"))],
)
chunks = chunk_document(title, body)
assert len(chunks) > 1, "a long log must split rather than truncate"
for chunk in chunks:
assert chunk.startswith("Short task title\n")
# The task's own prose still has a chunk of its own — it is not merged into
# a log section and scored as part of it.
assert any("Short body." in c and "alternative" not in c for c in chunks)
# And nothing is lost: this is what #280 exists for. Paragraph-shaped,
# because that is what a log is and because a single unbroken 3,000-char
# line is hard-split mid-line by design — `test_chunking` owns that case.
joined = "\n".join(chunks)
for line in body.splitlines():
if line.strip():
assert line.strip() in joined, f"content dropped: {line[:60]!r}"
def test_a_short_task_with_a_short_log_is_still_one_sharp_chunk():
"""Over-splitting is not the goal either. A task and one short log stay a
single document, the shape note #2485 measured as the sharpest."""
title, body = task_document("T", "body", [(D1, "a short note about it")])
assert chunk_document(title, body) == [embedding_text(title, body)]
# --- keeping it current: a log write refreshes the task's vectors ------------
#
# The shaper above is pure. These pin that the writers actually call it — a
# task whose logs are in the document but never re-indexed after one is written
# is #4241's half-surface one layer down: the entry is readable, and the search
# still answers as though it were never written.
from unittest.mock import AsyncMock, MagicMock, patch # noqa: E402
import pytest # noqa: E402
from scribe.services import task_logs as svc # noqa: E402
from tests.helpers import ( # noqa: E402
fake_note, make_mock_session, session_returning,
)
@pytest.mark.asyncio
async def test_writing_a_work_log_refreshes_the_tasks_embedding():
note = fake_note(id=7, title="A task", body="prose", is_task=True)
session = session_returning(note)
with (
patch.object(svc, "async_session", return_value=session),
patch.object(svc, "can_write_note", AsyncMock(return_value=True)),
patch("scribe.services.notes.embed_note") as embed,
):
await svc.create_log(42, 7, "what I tried")
embed.assert_called_once()
assert embed.call_args.args[0] is note
@pytest.mark.asyncio
async def test_editing_a_work_log_refreshes_it_too():
"""An edited log whose vectors still carry the old wording keeps matching
what it no longer says — the stale-vector case `upsert_note_embedding`
already refuses to leave behind for a body."""
note = fake_note(id=7, title="A task", body="prose", is_task=True)
log = MagicMock(id=3, task_id=7)
session = make_mock_session()
found_log, found_note = MagicMock(), MagicMock()
found_log.scalars.return_value.first.return_value = log
found_note.scalars.return_value.first.return_value = note
session.execute.side_effect = [found_log, found_note]
with (
patch.object(svc, "async_session", return_value=session),
patch("scribe.services.notes.embed_note") as embed,
):
await svc.update_log(42, 3, content="reworded")
embed.assert_called_once_with(note)
@pytest.mark.asyncio
async def test_deleting_a_work_log_refreshes_it_too():
"""And reads the task id BEFORE the row goes — after the delete there is
nothing left to ask which task it belonged to."""
note = fake_note(id=7, title="A task", body="prose", is_task=True)
log = MagicMock(id=3, task_id=7)
session = make_mock_session()
found_log, found_note = MagicMock(), MagicMock()
found_log.scalars.return_value.first.return_value = log
found_note.scalars.return_value.first.return_value = note
session.execute.side_effect = [found_log, found_note]
with (
patch.object(svc, "async_session", return_value=session),
patch("scribe.services.notes.embed_note") as embed,
):
assert await svc.delete_log(42, 3) is True
embed.assert_called_once_with(note)
@pytest.mark.asyncio
async def test_a_failed_refresh_does_not_fail_the_log_that_saved():
"""Indexing never breaks a write. The startup backfill is the backstop."""
session = session_returning(fake_note(id=7, title="T", body="b", is_task=True))
with (
patch.object(svc, "async_session", return_value=session),
patch.object(svc, "can_write_note", AsyncMock(return_value=True)),
patch("scribe.services.notes.embed_note", side_effect=RuntimeError("boom")),
):
log = await svc.create_log(42, 7, "what I tried")
assert log is not None
session.commit.assert_awaited()