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
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>
203 lines
8.6 KiB
Python
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()
|