CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m5s
CI & Build / Build & push image (push) Skipped
Milestone 385 step 2, implementing decision #4157 from the step-1 spike. The kind: `note_type='lesson'`, a note findable by WHEN IT APPLIES rather than by what it is about. The trigger lives in `notes.data.when_to_apply`, mirrored into the title and the head of the body — the shape snippets already use, and the reason nothing re-embeds: chunk_document is untouched, so CHUNKER_VERSION does not move. NO MIGRATION, and the step assumed there would be one. `note_type` carries no CHECK — only `task_kind` does (0056, 0065). Migration 0036 added it as plain Text with a server default and nothing has gated it since, so rule 36 has no whitelist to expand and #3128's failure mode (a value the database refuses) cannot arise for this column. The vocabulary that actually decides what a reader can reach is services.knowledge._FACETS, which since #3161 is one table feeding the door's validation, the counts and both dialects of the type filter — so the kind lands there in a single edit. ONE JOIN, not a fourth copy. `{subject} — {trigger}` had three implementations: rule_document, snippets.compose_title, and this step needed another. #3207 records what that costs, so the join moves to embeddings.trigger_title beside embedding_text and all three delegate. Behaviour is unchanged for rules and snippets; the guard calls each through its own public name, so a re-implementation fails it. The #3163 bill is stated in the service docstring rather than left to be inferred: versions, supersession, trash, the share ACL, tags, project and System tagging, chunked embeddings and the duplicate gate are all inherited; status/task_kind/milestone_id and recurrence are not, and verify_with/expires_when are available but outside the kind's contract. The status cell is the one that matters — `is_task` IS `status is not None`, so a lesson that acquired one would become a task. The integration guard asserts the WRITE rather than the constraint: it holds whether or not note_type is ever gated, and goes red only if it is gated without this value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
90 lines
3.4 KiB
Python
90 lines
3.4 KiB
Python
"""get_knowledge_counts — one number per facet, and an honest total.
|
|
|
|
Two grouped queries, one per typing axis. It used to be three: a grouped count
|
|
over note_type restricted to ("note", "process"), a scalar count of tasks, and
|
|
a second scalar just for plans. That shape is why `issue` had no number —
|
|
every kind needed a query of its own and nobody added one — and why the "All"
|
|
chip sat ~90 below the list it labelled, since snippets were in the feed but
|
|
in no count (#3128).
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from tests.helpers import make_mock_session
|
|
|
|
|
|
def _grouped(rows):
|
|
r = MagicMock()
|
|
r.all.return_value = rows
|
|
return r
|
|
|
|
|
|
async def _counts(non_task_rows, kind_rows, **kwargs):
|
|
session = make_mock_session()
|
|
# 1) non-task rows grouped by note_type, 2) task rows grouped by task_kind
|
|
session.execute = AsyncMock(
|
|
side_effect=[_grouped(non_task_rows), _grouped(kind_rows)]
|
|
)
|
|
with patch("scribe.services.knowledge.async_session") as cls:
|
|
cls.return_value = session
|
|
from scribe.services.knowledge import get_knowledge_counts
|
|
return await get_knowledge_counts(user_id=1, **kwargs), session
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_every_facet_gets_a_number_including_the_kinds():
|
|
counts, _ = await _counts(
|
|
[("note", 395), ("process", 3), ("snippet", 90)],
|
|
[("work", 2104), ("issue", 435), ("spike", 1), ("plan", 90)],
|
|
)
|
|
assert counts["note"] == 395
|
|
assert counts["process"] == 3
|
|
assert counts["snippet"] == 90
|
|
assert counts["issue"] == 435
|
|
assert counts["spike"] == 1
|
|
assert counts["plan"] == 90
|
|
assert counts["work"] == 2104
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_is_the_sum_of_its_kinds():
|
|
"""`task` is not counted separately any more — it is what the kinds add up
|
|
to, so the two can't disagree."""
|
|
counts, _ = await _counts([], [("work", 2104), ("issue", 435), ("spike", 1)])
|
|
assert counts["task"] == 2540
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_total_counts_snippets_and_counts_no_task_twice():
|
|
"""The All chip labels a feed that contains every kind, so it has to count
|
|
every kind — and exactly once. Kinds are subsets of `task`; adding them
|
|
would count each issue a second time."""
|
|
counts, _ = await _counts(
|
|
[("note", 10), ("process", 2), ("snippet", 5)],
|
|
[("work", 20), ("issue", 4)],
|
|
)
|
|
assert counts["task"] == 24
|
|
assert counts["total"] == 10 + 2 + 5 + 24
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_absent_facets_report_zero_rather_than_missing():
|
|
counts, _ = await _counts([("note", 1)], [])
|
|
assert counts["note"] == 1
|
|
# `lesson` is here from milestone 385 step 2: a kind added to the facet
|
|
# table and not to the counts would show an empty chip beside a feed that
|
|
# has rows in it, which is defect 3b in #3161 repeating itself.
|
|
for key in ("process", "snippet", "lesson", "task", "work", "issue", "spike", "plan"):
|
|
assert counts[key] == 0, key
|
|
assert counts["total"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_tag_filter_narrows_both_axes():
|
|
"""A tag has to reach both queries, or the chips would disagree with each
|
|
other under a filter — tasks narrowed, notes not."""
|
|
_, session = await _counts([], [], tags=["python"])
|
|
assert session.execute.await_count == 2
|
|
for call in session.execute.await_args_list:
|
|
assert "notes.tags" in str(call.args[0])
|