From 776f0a6469cba8eed9717f7a2e5217b676ce25a1 Mon Sep 17 00:00:00 2001 From: Leo Le Bleis Date: Sat, 13 Jun 2026 20:29:54 +0100 Subject: [PATCH 01/11] feat(db): add idempotent add_missing_columns migration helper --- src/brew/db.py | 15 +++++++++++++++ tests/test_db.py | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/brew/db.py b/src/brew/db.py index 441e58c..6bcdd82 100644 --- a/src/brew/db.py +++ b/src/brew/db.py @@ -22,3 +22,18 @@ async def init_db(conn: aiosqlite.Connection, schemas: list[list[str]]) -> None: for ddl in schema: await conn.execute(ddl) await conn.commit() + + +async def add_missing_columns(conn: aiosqlite.Connection, table: str, columns: dict[str, str]) -> None: + """Additively ALTER in any columns not already present on `table`. + + SQLite has no `ADD COLUMN IF NOT EXISTS`, so we diff against PRAGMA table_info. + `columns` maps column name -> the DDL type/constraint fragment, e.g. + {"varietal": "TEXT", "acidity": "INTEGER CHECK (acidity IS NULL OR acidity BETWEEN -2 AND 2)"}. + """ + cursor = await conn.execute(f"PRAGMA table_info({table})") + existing = {row["name"] for row in await cursor.fetchall()} + for name, ddl in columns.items(): + if name not in existing: + await conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}") + await conn.commit() diff --git a/tests/test_db.py b/tests/test_db.py index 5f2c66d..486bbd5 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,4 +1,6 @@ -from brew.db import init_db, open_db +import aiosqlite + +from brew.db import add_missing_columns, init_db, open_db async def test_open_db_returns_connection_with_foreign_keys_on() -> None: @@ -39,3 +41,35 @@ async def test_init_db_runs_multiple_schema_lists() -> None: assert [r[0] for r in rows] == ["a", "b"] finally: await conn.close() + + +async def test_add_missing_columns_adds_only_absent_ones() -> None: + conn = await aiosqlite.connect(":memory:") + conn.row_factory = aiosqlite.Row + await conn.execute("CREATE TABLE t (id TEXT PRIMARY KEY, a TEXT)") + await conn.execute("INSERT INTO t (id, a) VALUES ('1', 'x')") + await conn.commit() + + await add_missing_columns(conn, "t", {"a": "TEXT", "b": "INTEGER", "c": "TEXT NOT NULL DEFAULT '[]'"}) + + cursor = await conn.execute("PRAGMA table_info(t)") + cols = {row["name"] for row in await cursor.fetchall()} + assert {"id", "a", "b", "c"} <= cols + + cursor = await conn.execute("SELECT b, c FROM t WHERE id = '1'") + row = await cursor.fetchone() + assert row["b"] is None + assert row["c"] == "[]" + await conn.close() + + +async def test_add_missing_columns_is_idempotent() -> None: + conn = await aiosqlite.connect(":memory:") + conn.row_factory = aiosqlite.Row + await conn.execute("CREATE TABLE t (id TEXT PRIMARY KEY)") + await conn.commit() + await add_missing_columns(conn, "t", {"b": "INTEGER"}) + await add_missing_columns(conn, "t", {"b": "INTEGER"}) # must not raise + cursor = await conn.execute("PRAGMA table_info(t)") + assert {row["name"] for row in await cursor.fetchall()} == {"id", "b"} + await conn.close() From c658603f7c125bfd41140ef6850e374b07987ded Mon Sep 17 00:00:00 2001 From: Leo Le Bleis Date: Sat, 13 Jun 2026 20:36:16 +0100 Subject: [PATCH 02/11] feat(bags): add varietal/process/altitude_masl bean dimensions --- src/brew/bags/model/api/requests.py | 6 +++ src/brew/bags/model/api/responses.py | 3 ++ src/brew/bags/model/bag.py | 9 ++++ src/brew/bags/repository.py | 75 ++++++++++++++++++---------- src/brew/bags/schema.py | 5 +- tests/bags/test_bag_dimensions.py | 46 +++++++++++++++++ 6 files changed, 118 insertions(+), 26 deletions(-) create mode 100644 tests/bags/test_bag_dimensions.py diff --git a/src/brew/bags/model/api/requests.py b/src/brew/bags/model/api/requests.py index 8b1ea4f..e7b34aa 100644 --- a/src/brew/bags/model/api/requests.py +++ b/src/brew/bags/model/api/requests.py @@ -13,6 +13,9 @@ class BagCreateAPIRequest(BaseModel): profile_snapshot: dict[str, Any] roast_date: date | None = None profile_id: str | None = None + varietal: str | None = None + process: str | None = None + altitude_masl: int | None = Field(default=None, ge=0) class BagUpdateAPIRequest(BaseModel): @@ -23,3 +26,6 @@ class BagUpdateAPIRequest(BaseModel): roast_level: str | None = Field(default=None, min_length=1) profile_id: str | None = None profile_snapshot: dict[str, Any] | None = None + varietal: str | None = None + process: str | None = None + altitude_masl: int | None = Field(default=None, ge=0) diff --git a/src/brew/bags/model/api/responses.py b/src/brew/bags/model/api/responses.py index 0c03a0b..e56e18f 100644 --- a/src/brew/bags/model/api/responses.py +++ b/src/brew/bags/model/api/responses.py @@ -18,3 +18,6 @@ class BagAPIResponse(BaseModel): finished_at: datetime | None profile_id: str | None profile_snapshot: dict[str, Any] + varietal: str | None + process: str | None + altitude_masl: int | None diff --git a/src/brew/bags/model/bag.py b/src/brew/bags/model/bag.py index 1d46422..00578b8 100644 --- a/src/brew/bags/model/bag.py +++ b/src/brew/bags/model/bag.py @@ -24,6 +24,9 @@ class Bag: finished_at: datetime | None profile_id: str | None profile_snapshot: dict[str, Any] + varietal: str | None = None + process: str | None = None + altitude_masl: int | None = None @dataclass(frozen=True) @@ -36,6 +39,9 @@ class BagCreate: profile_snapshot: dict[str, Any] roast_date: date | None = None profile_id: str | None = None + varietal: str | None = None + process: str | None = None + altitude_masl: int | None = None @dataclass(frozen=True) @@ -47,3 +53,6 @@ class BagUpdate: roast_level: str | None = None profile_id: str | None = None profile_snapshot: dict[str, Any] | None = None + varietal: str | None = None + process: str | None = None + altitude_masl: int | None = None diff --git a/src/brew/bags/repository.py b/src/brew/bags/repository.py index d15842b..c8a2d74 100644 --- a/src/brew/bags/repository.py +++ b/src/brew/bags/repository.py @@ -55,9 +55,52 @@ def _row_to_bag(row: aiosqlite.Row) -> Bag: finished_at=_parse_datetime(row["finished_at"]) if row["finished_at"] else None, profile_id=row["profile_id"], profile_snapshot=json.loads(row["profile_snapshot"]), + varietal=row["varietal"], + process=row["process"], + altitude_masl=row["altitude_masl"], ) +def _build_update_clauses(update: BagUpdate) -> tuple[list[str], list[object]]: + sets: list[str] = [] + params: list[object] = [] + if update.name is not None: + sets.append("name = ?") + params.append(update.name) + if update.origin is not None: + sets.append("origin = ?") + params.append(update.origin) + if update.roaster is not None: + sets.append("roaster = ?") + params.append(update.roaster) + if update.roast_date is not None: + sets.append("roast_date = ?") + params.append(update.roast_date.isoformat()) + if update.roast_level is not None: + sets.append("roast_level = ?") + params.append(update.roast_level) + if update.profile_id is not None: + sets.append("profile_id = ?") + params.append(update.profile_id) + if update.profile_snapshot is not None: + sets.append("profile_snapshot = ?") + params.append(json.dumps(update.profile_snapshot)) + _append_dimension_clauses(update, sets, params) + return sets, params + + +def _append_dimension_clauses(update: BagUpdate, sets: list[str], params: list[object]) -> None: + if update.varietal is not None: + sets.append("varietal = ?") + params.append(update.varietal) + if update.process is not None: + sets.append("process = ?") + params.append(update.process) + if update.altitude_masl is not None: + sets.append("altitude_masl = ?") + params.append(update.altitude_masl) + + class BagSqliteRepository: def __init__(self, conn: aiosqlite.Connection) -> None: self._conn = conn @@ -70,8 +113,9 @@ async def create(self, create: BagCreate) -> Bag: INSERT INTO bags ( id, name, origin, roaster, roast_date, roast_level, initial_grams, remaining_grams, is_active, opened_at, - finished_at, profile_id, profile_snapshot - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?) + finished_at, profile_id, profile_snapshot, + varietal, process, altitude_masl + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?, ?, ?, ?) """, ( bag_id, @@ -85,6 +129,9 @@ async def create(self, create: BagCreate) -> Bag: opened_at, create.profile_id, json.dumps(create.profile_snapshot), + create.varietal, + create.process, + create.altitude_masl, ), ) await self._conn.commit() @@ -142,29 +189,7 @@ async def list( return [_row_to_bag(row) for row in rows] async def update(self, bag_id: str, update: BagUpdate) -> bool: - sets: list[str] = [] - params: list[object] = [] - if update.name is not None: - sets.append("name = ?") - params.append(update.name) - if update.origin is not None: - sets.append("origin = ?") - params.append(update.origin) - if update.roaster is not None: - sets.append("roaster = ?") - params.append(update.roaster) - if update.roast_date is not None: - sets.append("roast_date = ?") - params.append(update.roast_date.isoformat()) - if update.roast_level is not None: - sets.append("roast_level = ?") - params.append(update.roast_level) - if update.profile_id is not None: - sets.append("profile_id = ?") - params.append(update.profile_id) - if update.profile_snapshot is not None: - sets.append("profile_snapshot = ?") - params.append(json.dumps(update.profile_snapshot)) + sets, params = _build_update_clauses(update) if not sets: return await self.get(bag_id) is not None diff --git a/src/brew/bags/schema.py b/src/brew/bags/schema.py index b00b55d..dabfd29 100644 --- a/src/brew/bags/schema.py +++ b/src/brew/bags/schema.py @@ -26,7 +26,10 @@ opened_at TEXT NOT NULL, finished_at TEXT, profile_id TEXT, - profile_snapshot TEXT NOT NULL + profile_snapshot TEXT NOT NULL, + varietal TEXT, + process TEXT, + altitude_masl INTEGER ) """, ] diff --git a/tests/bags/test_bag_dimensions.py b/tests/bags/test_bag_dimensions.py new file mode 100644 index 0000000..6f5a6e8 --- /dev/null +++ b/tests/bags/test_bag_dimensions.py @@ -0,0 +1,46 @@ +import aiosqlite + +from brew.bags.model.bag import BagCreate, BagUpdate +from brew.bags.repository import BagSqliteRepository +from brew.bags.schema import BAGS_SCHEMA + + +async def _repo() -> BagSqliteRepository: + conn = await aiosqlite.connect(":memory:") + conn.row_factory = aiosqlite.Row + for ddl in BAGS_SCHEMA: + await conn.execute(ddl) + await conn.commit() + return BagSqliteRepository(conn) + + +async def test_create_persists_bean_dimensions() -> None: + repo = await _repo() + bag = await repo.create( + BagCreate( + name="Spring Bloom", + origin="Huila", + roaster="Intermission", + roast_level="light", + initial_grams=250, + profile_snapshot={}, + varietal="Geisha", + process="natural", + altitude_masl=1650, + ) + ) + assert bag.varietal == "Geisha" + assert bag.process == "natural" + assert bag.altitude_masl == 1650 + + +async def test_update_changes_dimensions() -> None: + repo = await _repo() + bag = await repo.create( + BagCreate(name="x", origin="o", roaster="r", roast_level="light", initial_grams=250, profile_snapshot={}) + ) + assert bag.varietal is None + await repo.update(bag.id, BagUpdate(varietal="Bourbon", process="washed")) + refreshed = await repo.get(bag.id) + assert refreshed.varietal == "Bourbon" + assert refreshed.process == "washed" From 732172b55c960496361c9b833185d6847fc157b0 Mon Sep 17 00:00:00 2001 From: Leo Le Bleis Date: Sat, 13 Jun 2026 20:38:23 +0100 Subject: [PATCH 03/11] feat(journal): add TastingAxes + axes/tags/bean-dim-snapshot to JournalEntry --- src/brew/journal/model/entry.py | 20 +++++++++++++++++++- tests/journal/test_entry_model.py | 27 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tests/journal/test_entry_model.py diff --git a/src/brew/journal/model/entry.py b/src/brew/journal/model/entry.py index f6e4798..f263c7d 100644 --- a/src/brew/journal/model/entry.py +++ b/src/brew/journal/model/entry.py @@ -5,11 +5,22 @@ later recipe tweaks don't rewrite historical tasting notes. """ -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from typing import Any +@dataclass(frozen=True) +class TastingAxes: + """Signed -2..+2 tasting axes (0 = balanced). All optional until rated.""" + + acidity: int | None = None + bitterness: int | None = None + body: int | None = None + sweetness: int | None = None + strength: int | None = None + + @dataclass(frozen=True) class JournalEntry: id: str @@ -23,6 +34,13 @@ class JournalEntry: rating: int | None note_text: str | None created_at: datetime + acidity: int | None = None + bitterness: int | None = None + body: int | None = None + sweetness: int | None = None + strength: int | None = None + flavor_tags: list[str] = field(default_factory=list) + bean_dimensions_snapshot: dict[str, Any] | None = None @dataclass(frozen=True) diff --git a/tests/journal/test_entry_model.py b/tests/journal/test_entry_model.py new file mode 100644 index 0000000..6c4ee4f --- /dev/null +++ b/tests/journal/test_entry_model.py @@ -0,0 +1,27 @@ +from datetime import UTC, datetime + +from brew.journal.model.entry import JournalEntry, TastingAxes + + +def test_entry_defaults_for_new_fields() -> None: + entry = JournalEntry( + id="e1", + brew_started_at=datetime(2026, 6, 13, 7, 0, tzinfo=UTC), + brew_ended_at=datetime(2026, 6, 13, 7, 7, tzinfo=UTC), + bag_id="b1", + profile_id="p1", + profile_snapshot_at_brew={}, + water_ml=450, + dose_grams=27, + rating=None, + note_text=None, + created_at=datetime(2026, 6, 13, 7, 7, tzinfo=UTC), + ) + assert entry.acidity is None + assert entry.flavor_tags == [] + assert entry.bean_dimensions_snapshot is None + + +def test_tasting_axes_holds_five_signed_axes() -> None: + axes = TastingAxes(acidity=2, bitterness=0, body=-1, sweetness=1, strength=0) + assert (axes.acidity, axes.bitterness, axes.body, axes.sweetness, axes.strength) == (2, 0, -1, 1, 0) From 1a402ea9c73e8923caccbe32c9a2813d8c7e4418 Mon Sep 17 00:00:00 2001 From: Leo Le Bleis Date: Sat, 13 Jun 2026 20:47:48 +0100 Subject: [PATCH 04/11] feat(journal): persist tasting axes, flavor tags, bean-dim snapshot --- src/brew/journal/repository.py | 74 ++++++++++++++++++++++-- src/brew/journal/schema.py | 9 ++- tests/journal/test_repository_tasting.py | 59 +++++++++++++++++++ 3 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 tests/journal/test_repository_tasting.py diff --git a/src/brew/journal/repository.py b/src/brew/journal/repository.py index 8a6828c..7ec388c 100644 --- a/src/brew/journal/repository.py +++ b/src/brew/journal/repository.py @@ -1,14 +1,19 @@ """Journal repository — Protocol + aiosqlite implementation.""" +from __future__ import annotations + import json import uuid from datetime import datetime -from typing import Protocol +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + import builtins -import aiosqlite + import aiosqlite from brew.datetime_utils import now_iso, to_iso -from brew.journal.model.entry import JournalEntry, JournalEntryCreate +from brew.journal.model.entry import JournalEntry, JournalEntryCreate, TastingAxes class JournalRepository(Protocol): @@ -22,9 +27,19 @@ async def list( since: datetime | None = None, rating_min: int | None = None, limit: int = 100, - ) -> list[JournalEntry]: ... + ) -> builtins.list[JournalEntry]: ... async def update(self, entry_id: str, *, rating: int | None, note_text: str | None) -> bool: ... async def delete(self, entry_id: str) -> bool: ... + async def record_tasting( # noqa: PLR0913 + self, + entry_id: str, + *, + axes: TastingAxes | None = None, + flavor_tags: builtins.list[str] | None = None, + note_text: str | None = None, + rating: int | None = None, + bean_dimensions_snapshot: dict | None = None, + ) -> bool: ... def _parse_datetime(value: str) -> datetime: @@ -44,6 +59,15 @@ def _row_to_entry(row: aiosqlite.Row) -> JournalEntry: rating=row["rating"], note_text=row["note_text"], created_at=_parse_datetime(row["created_at"]), + acidity=row["acidity"], + bitterness=row["bitterness"], + body=row["body"], + sweetness=row["sweetness"], + strength=row["strength"], + flavor_tags=json.loads(row["flavor_tags"]), + bean_dimensions_snapshot=( + json.loads(row["bean_dimensions_snapshot"]) if row["bean_dimensions_snapshot"] else None + ), ) @@ -93,7 +117,7 @@ async def list( since: datetime | None = None, rating_min: int | None = None, limit: int = 100, - ) -> list[JournalEntry]: + ) -> builtins.list[JournalEntry]: clauses: list[str] = [] params: list[object] = [] if bag_id is not None: @@ -138,6 +162,46 @@ async def update(self, entry_id: str, *, rating: int | None, note_text: str | No await self._conn.commit() return cursor.rowcount > 0 + async def record_tasting( # noqa: PLR0913 + self, + entry_id: str, + *, + axes: TastingAxes | None = None, + flavor_tags: builtins.list[str] | None = None, + note_text: str | None = None, + rating: int | None = None, + bean_dimensions_snapshot: dict | None = None, + ) -> bool: + sets: list[str] = [] + params: list[object] = [] + if axes is not None: + for name in ("acidity", "bitterness", "body", "sweetness", "strength"): + value = getattr(axes, name) + if value is not None: + sets.append(f"{name} = ?") + params.append(value) + if flavor_tags is not None: + sets.append("flavor_tags = ?") + params.append(json.dumps(flavor_tags)) + if note_text is not None: + sets.append("note_text = ?") + params.append(note_text) + if rating is not None: + sets.append("rating = ?") + params.append(rating) + if bean_dimensions_snapshot is not None: + sets.append("bean_dimensions_snapshot = ?") + params.append(json.dumps(bean_dimensions_snapshot)) + + if not sets: + return await self.get(entry_id) is not None + + sql = f"UPDATE journal_entries SET {', '.join(sets)} WHERE id = ?" # noqa: S608 + params.append(entry_id) + cursor = await self._conn.execute(sql, tuple(params)) + await self._conn.commit() + return cursor.rowcount > 0 + async def delete(self, entry_id: str) -> bool: cursor = await self._conn.execute("DELETE FROM journal_entries WHERE id = ?", (entry_id,)) await self._conn.commit() diff --git a/src/brew/journal/schema.py b/src/brew/journal/schema.py index 63ad540..63d70e0 100644 --- a/src/brew/journal/schema.py +++ b/src/brew/journal/schema.py @@ -22,7 +22,14 @@ dose_grams INTEGER NOT NULL, rating INTEGER CHECK (rating IS NULL OR rating BETWEEN 1 AND 5), note_text TEXT, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + acidity INTEGER CHECK (acidity IS NULL OR acidity BETWEEN -2 AND 2), + bitterness INTEGER CHECK (bitterness IS NULL OR bitterness BETWEEN -2 AND 2), + body INTEGER CHECK (body IS NULL OR body BETWEEN -2 AND 2), + sweetness INTEGER CHECK (sweetness IS NULL OR sweetness BETWEEN -2 AND 2), + strength INTEGER CHECK (strength IS NULL OR strength BETWEEN -2 AND 2), + flavor_tags TEXT NOT NULL DEFAULT '[]', + bean_dimensions_snapshot TEXT ) """, "CREATE INDEX IF NOT EXISTS idx_journal_brew_ended_at ON journal_entries(brew_ended_at DESC)", diff --git a/tests/journal/test_repository_tasting.py b/tests/journal/test_repository_tasting.py new file mode 100644 index 0000000..4ecf629 --- /dev/null +++ b/tests/journal/test_repository_tasting.py @@ -0,0 +1,59 @@ +from datetime import UTC, datetime + +import aiosqlite + +from brew.journal.model.entry import JournalEntryCreate, TastingAxes +from brew.journal.repository import JournalSqliteRepository +from brew.journal.schema import JOURNAL_SCHEMA + + +async def _repo() -> JournalSqliteRepository: + conn = await aiosqlite.connect(":memory:") + conn.row_factory = aiosqlite.Row + for ddl in JOURNAL_SCHEMA: + await conn.execute(ddl) + await conn.commit() + return JournalSqliteRepository(conn) + + +def _make_create(**kw) -> JournalEntryCreate: + base: dict = { + "brew_started_at": datetime(2026, 6, 13, 7, 0, tzinfo=UTC), + "brew_ended_at": datetime(2026, 6, 13, 7, 7, tzinfo=UTC), + "bag_id": "b1", + "profile_id": "p1", + "profile_snapshot_at_brew": {}, + "water_ml": 450, + "dose_grams": 27, + } + base.update(kw) + return JournalEntryCreate(**base) + + +async def test_new_entry_has_empty_tags_and_null_axes() -> None: + repo = await _repo() + entry = await repo.create(_make_create()) + assert entry.flavor_tags == [] + assert entry.acidity is None + assert entry.bean_dimensions_snapshot is None + + +async def test_record_tasting_persists_axes_tags_snapshot() -> None: + repo = await _repo() + entry = await repo.create(_make_create()) + ok = await repo.record_tasting( + entry.id, + axes=TastingAxes(acidity=2, bitterness=0, body=-1, sweetness=1, strength=0), + flavor_tags=["floral", "berry"], + note_text="sharp, hollow middle", + rating=3, + bean_dimensions_snapshot={"varietal": "Geisha", "process": "natural", "roast_level": "light"}, + ) + assert ok is True + refreshed = await repo.get(entry.id) + assert refreshed.acidity == 2 + assert refreshed.sweetness == 1 + assert refreshed.flavor_tags == ["floral", "berry"] + assert refreshed.note_text == "sharp, hollow middle" + assert refreshed.rating == 3 + assert refreshed.bean_dimensions_snapshot["varietal"] == "Geisha" From 1cc315e046228652842e804e1e68485b4764b7b8 Mon Sep 17 00:00:00 2001 From: Leo Le Bleis Date: Sat, 13 Jun 2026 20:52:46 +0100 Subject: [PATCH 05/11] feat(journal): record_tasting service + axes/tags API surface --- src/brew/journal/model/api/requests.py | 6 ++++ src/brew/journal/model/api/responses.py | 7 +++++ src/brew/journal/router.py | 16 +++++++++-- src/brew/journal/service.py | 38 +++++++++++++++++++++---- tests/journal/test_router.py | 23 +++++++++++---- tests/journal/test_service_tasting.py | 32 +++++++++++++++++++++ 6 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 tests/journal/test_service_tasting.py diff --git a/src/brew/journal/model/api/requests.py b/src/brew/journal/model/api/requests.py index a086fe8..f5da889 100644 --- a/src/brew/journal/model/api/requests.py +++ b/src/brew/journal/model/api/requests.py @@ -6,6 +6,12 @@ class JournalEntryUpdateAPIRequest(BaseModel): rating: int | None = Field(default=None, ge=1, le=5) note_text: str | None = None + acidity: int | None = Field(default=None, ge=-2, le=2) + bitterness: int | None = Field(default=None, ge=-2, le=2) + body: int | None = Field(default=None, ge=-2, le=2) + sweetness: int | None = Field(default=None, ge=-2, le=2) + strength: int | None = Field(default=None, ge=-2, le=2) + flavor_tags: list[str] | None = None class JournalEntryCreateAPIRequest(BaseModel): diff --git a/src/brew/journal/model/api/responses.py b/src/brew/journal/model/api/responses.py index 5680ca9..a2c9ff7 100644 --- a/src/brew/journal/model/api/responses.py +++ b/src/brew/journal/model/api/responses.py @@ -16,3 +16,10 @@ class JournalEntryAPIResponse(BaseModel): rating: int | None note_text: str | None created_at: datetime + acidity: int | None + bitterness: int | None + body: int | None + sweetness: int | None + strength: int | None + flavor_tags: list[str] + bean_dimensions_snapshot: dict[str, Any] | None diff --git a/src/brew/journal/router.py b/src/brew/journal/router.py index 050b9b6..5a2fe61 100644 --- a/src/brew/journal/router.py +++ b/src/brew/journal/router.py @@ -13,7 +13,7 @@ JournalEntryUpdateAPIRequest, ) from brew.journal.model.api.responses import JournalEntryAPIResponse -from brew.journal.model.entry import JournalEntryCreate +from brew.journal.model.entry import JournalEntryCreate, TastingAxes from brew.journal.service import JournalService router = APIRouter(prefix="/journal", tags=["journal"]) @@ -86,7 +86,19 @@ async def update_entry( request: JournalEntryUpdateAPIRequest, service: Annotated[JournalService, Depends(get_journal_service)], ) -> dict[str, str]: - await service.update(entry_id, rating=request.rating, note_text=request.note_text) + await service.record_tasting( + entry_id, + axes=TastingAxes( + acidity=request.acidity, + bitterness=request.bitterness, + body=request.body, + sweetness=request.sweetness, + strength=request.strength, + ), + flavor_tags=request.flavor_tags, + note_text=request.note_text, + rating=request.rating, + ) return {"status": "ok"} diff --git a/src/brew/journal/service.py b/src/brew/journal/service.py index d9cb76e..3c76f89 100644 --- a/src/brew/journal/service.py +++ b/src/brew/journal/service.py @@ -1,12 +1,19 @@ """Journal service — orchestrates the journal repository.""" -from datetime import datetime +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import builtins + from datetime import datetime + + from brew.events.bus import EventBus + from brew.journal.model.entry import JournalEntry, JournalEntryCreate, TastingAxes + from brew.journal.repository import JournalRepository from brew.errors import NotFoundError -from brew.events.bus import EventBus from brew.events.domain import JournalEntryCreated -from brew.journal.model.entry import JournalEntry, JournalEntryCreate -from brew.journal.repository import JournalRepository _KIND = "journal_entry" @@ -46,7 +53,7 @@ async def list( since: datetime | None = None, rating_min: int | None = None, limit: int = 100, - ) -> list[JournalEntry]: + ) -> builtins.list[JournalEntry]: return await self._repo.list( bag_id=bag_id, profile_id=profile_id, @@ -59,6 +66,27 @@ async def update(self, entry_id: str, *, rating: int | None, note_text: str | No if not await self._repo.update(entry_id, rating=rating, note_text=note_text): raise NotFoundError.for_resource(_KIND, entry_id) + async def record_tasting( # noqa: PLR0913 + self, + entry_id: str, + *, + axes: TastingAxes | None = None, + flavor_tags: builtins.list[str] | None = None, + note_text: str | None = None, + rating: int | None = None, + bean_dimensions_snapshot: dict | None = None, + ) -> None: + ok = await self._repo.record_tasting( + entry_id, + axes=axes, + flavor_tags=flavor_tags, + note_text=note_text, + rating=rating, + bean_dimensions_snapshot=bean_dimensions_snapshot, + ) + if not ok: + raise NotFoundError.for_resource(_KIND, entry_id) + async def delete(self, entry_id: str) -> None: if not await self._repo.delete(entry_id): raise NotFoundError.for_resource(_KIND, entry_id) diff --git a/tests/journal/test_router.py b/tests/journal/test_router.py index 0b506b2..c9ed3df 100644 --- a/tests/journal/test_router.py +++ b/tests/journal/test_router.py @@ -6,6 +6,7 @@ from brew.errors import NotFoundError from brew.journal.dependencies import get_journal_service +from brew.journal.model.entry import TastingAxes from brew.main import app from tests.journal.conftest import make_entry @@ -62,21 +63,33 @@ async def test_get_returns_404_when_missing(client: AsyncClient, mock_service: A async def test_patch_rating_returns_200(client: AsyncClient, mock_service: AsyncMock) -> None: - mock_service.update.return_value = None + mock_service.record_tasting.return_value = None response = await client.patch("/api/journal/e1", json={"rating": 4}) assert response.status_code == 200 - mock_service.update.assert_awaited_once_with("e1", rating=4, note_text=None) + mock_service.record_tasting.assert_awaited_once_with( + "e1", + axes=TastingAxes(), + flavor_tags=None, + note_text=None, + rating=4, + ) async def test_patch_note_returns_200(client: AsyncClient, mock_service: AsyncMock) -> None: - mock_service.update.return_value = None + mock_service.record_tasting.return_value = None response = await client.patch("/api/journal/e1", json={"note_text": "Caramel"}) assert response.status_code == 200 - mock_service.update.assert_awaited_once_with("e1", rating=None, note_text="Caramel") + mock_service.record_tasting.assert_awaited_once_with( + "e1", + axes=TastingAxes(), + flavor_tags=None, + note_text="Caramel", + rating=None, + ) async def test_patch_rating_out_of_range_returns_422(client: AsyncClient) -> None: @@ -86,7 +99,7 @@ async def test_patch_rating_out_of_range_returns_422(client: AsyncClient) -> Non async def test_patch_returns_404_when_missing(client: AsyncClient, mock_service: AsyncMock) -> None: - mock_service.update.side_effect = NotFoundError( + mock_service.record_tasting.side_effect = NotFoundError( message="not found", resource_kind="journal_entry", resource_id="e1" ) diff --git a/tests/journal/test_service_tasting.py b/tests/journal/test_service_tasting.py new file mode 100644 index 0000000..1e120d0 --- /dev/null +++ b/tests/journal/test_service_tasting.py @@ -0,0 +1,32 @@ +from unittest.mock import AsyncMock + +import pytest + +from brew.errors import NotFoundError +from brew.events.bus import EventBus +from brew.journal.model.entry import TastingAxes +from brew.journal.service import JournalService + + +async def test_record_tasting_delegates_to_repo() -> None: + repo = AsyncMock() + repo.record_tasting.return_value = True + service = JournalService(repo=repo, bus=EventBus()) + axes = TastingAxes(acidity=2) + await service.record_tasting("e1", axes=axes, flavor_tags=["floral"], note_text="sharp", rating=3) + repo.record_tasting.assert_awaited_once_with( + "e1", + axes=axes, + flavor_tags=["floral"], + note_text="sharp", + rating=3, + bean_dimensions_snapshot=None, + ) + + +async def test_record_tasting_raises_when_missing() -> None: + repo = AsyncMock() + repo.record_tasting.return_value = False + service = JournalService(repo=repo, bus=EventBus()) + with pytest.raises(NotFoundError): + await service.record_tasting("nope", axes=TastingAxes(acidity=1)) From b698f06ffbb2359c9237a2acdb9cfab787d3ad0a Mon Sep 17 00:00:00 2001 From: Leo Le Bleis Date: Sat, 13 Jun 2026 20:57:39 +0100 Subject: [PATCH 06/11] feat(journal): palate weighted-KNN tendency read-model --- src/brew/journal/palate.py | 139 +++++++++++++++++++++++++++++++++++ tests/journal/test_palate.py | 80 ++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 src/brew/journal/palate.py create mode 100644 tests/journal/test_palate.py diff --git a/src/brew/journal/palate.py b/src/brew/journal/palate.py new file mode 100644 index 0000000..3ba75a6 --- /dev/null +++ b/src/brew/journal/palate.py @@ -0,0 +1,139 @@ +"""Palate read-model — predicts a bean's likely tasting outcome from similar past brews. + +Content-based weighted KNN over the journal: each rated entry's frozen +`bean_dimensions_snapshot` is compared to the query bean; neighbours are weighted by +(1 - distance) and their tasting axes averaged. Exact brute-force — at personal scale +(hundreds of brews) there is no need for ANN/vector indexes. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import builtins + + from brew.journal.repository import JournalRepository + +_AXES = ("acidity", "bitterness", "body", "sweetness", "strength") +_WEIGHTS = { + "varietal": 0.30, + "process": 0.30, + "roast_level": 0.20, + "origin": 0.10, + "altitude_masl": 0.05, + "flavor_tags": 0.05, +} +_ROAST_ORDER = {"light": 0, "medium-light": 1, "medium": 2, "medium-dark": 3, "dark": 4} +_SIMILARITY_THRESHOLD = 0.25 +_MIN_NEIGHBOURS = 2 +_FULL_CONFIDENCE_SIM = 3.0 + + +@dataclass(frozen=True) +class BeanDimensions: + varietal: str | None = None + process: str | None = None + roast_level: str | None = None + origin: str | None = None + altitude_masl: int | None = None + flavor_tags: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class PalateTendency: + tendency: dict[str, float] + confidence: float + n: int + neighbours: list[dict] + + +def _categorical(a: str | None, b: str | None) -> float: + if a is None and b is None: + return 0.0 # both absent — no mismatch + if a is None or b is None: + return 0.5 # one known, one unknown — partial penalty + return 0.0 if a.lower() == b.lower() else 1.0 + + +def _roast_distance(a: str | None, b: str | None) -> float: + if a is None and b is None: + return 0.0 # both absent — no mismatch + if a is None or b is None: + return 0.5 + ai, bi = _ROAST_ORDER.get(a.lower()), _ROAST_ORDER.get(b.lower()) + if ai is None or bi is None: + return _categorical(a, b) + return abs(ai - bi) / (len(_ROAST_ORDER) - 1) + + +def _altitude_distance(a: int | None, b: int | None) -> float: + if a is None and b is None: + return 0.0 # both absent — no mismatch + if a is None or b is None: + return 0.5 # one known, one unknown — partial penalty + return min(1.0, abs(a - b) / 1000.0) + + +def _tag_distance(a: builtins.list[str], b: builtins.list[str]) -> float: + if not a and not b: + return 0.0 # both absent — no mismatch + if not a or not b: + return 0.5 # one known, one unknown — partial penalty + sa, sb = {t.lower() for t in a}, {t.lower() for t in b} + return 1.0 - len(sa & sb) / len(sa | sb) + + +def dimension_distance( + query: BeanDimensions, snapshot: dict, query_tags: builtins.list[str], entry_tags: builtins.list[str] +) -> float: + """Weighted sum of per-feature distances in [0, 1].""" + parts = { + "varietal": _categorical(query.varietal, snapshot.get("varietal")), + "process": _categorical(query.process, snapshot.get("process")), + "roast_level": _roast_distance(query.roast_level, snapshot.get("roast_level")), + "origin": _categorical(query.origin, snapshot.get("origin")), + "altitude_masl": _altitude_distance(query.altitude_masl, snapshot.get("altitude_masl")), + "flavor_tags": _tag_distance(query_tags, entry_tags), + } + return sum(_WEIGHTS[k] * v for k, v in parts.items()) + + +class PalateQuery: + def __init__(self, repo: JournalRepository) -> None: + self._repo = repo + + async def tendency_for(self, query: BeanDimensions, *, limit: int = 500) -> PalateTendency: + entries = await self._repo.list(rating_min=1, limit=limit) + scored: list[tuple[float, object]] = [] + for e in entries: + if e.bean_dimensions_snapshot is None: + continue + dist = dimension_distance(query, e.bean_dimensions_snapshot, query.flavor_tags, e.flavor_tags) + sim = 1.0 - dist + if sim >= _SIMILARITY_THRESHOLD: + scored.append((sim, e)) + + if len(scored) < _MIN_NEIGHBOURS: + return PalateTendency(tendency={}, confidence=0.0, n=len(scored), neighbours=[]) + + total_sim = sum(sim for sim, _ in scored) + tendency: dict[str, float] = {} + for axis in _AXES: + weighted = [(sim, getattr(e, axis)) for sim, e in scored if getattr(e, axis) is not None] + axis_total = sum(sim for sim, _ in weighted) + if axis_total > 0: + tendency[axis] = round(sum(sim * val for sim, val in weighted) / axis_total, 2) + + confidence = round(min(1.0, total_sim / _FULL_CONFIDENCE_SIM), 2) + neighbours = [ + { + "entry_id": e.id, # ty: ignore[unresolved-attribute] + "similarity": round(sim, 2), + "axes": {a: getattr(e, a) for a in _AXES}, + "rating": e.rating, # ty: ignore[unresolved-attribute] + } + for sim, e in sorted(scored, key=lambda x: x[0], reverse=True) + ] + return PalateTendency(tendency=tendency, confidence=confidence, n=len(scored), neighbours=neighbours) diff --git a/tests/journal/test_palate.py b/tests/journal/test_palate.py new file mode 100644 index 0000000..cefb5be --- /dev/null +++ b/tests/journal/test_palate.py @@ -0,0 +1,80 @@ +from unittest.mock import AsyncMock + +from brew.journal.palate import BeanDimensions, PalateQuery, dimension_distance + +from tests.journal.conftest import make_entry + + +def _snap(varietal=None, process=None, roast_level=None, origin=None, altitude_masl=None): + return { + "varietal": varietal, + "process": process, + "roast_level": roast_level, + "origin": origin, + "altitude_masl": altitude_masl, + } + + +def test_distance_zero_for_identical_dimensions() -> None: + q = BeanDimensions(varietal="Geisha", process="natural", roast_level="light") + d = dimension_distance(q, _snap(varietal="Geisha", process="natural", roast_level="light"), [], []) + assert d == 0.0 + + +def test_distance_grows_with_mismatch() -> None: + q = BeanDimensions(varietal="Geisha", process="natural", roast_level="light") + same = dimension_distance(q, _snap(varietal="Geisha", process="natural", roast_level="light"), [], []) + diff = dimension_distance(q, _snap(varietal="Bourbon", process="washed", roast_level="dark"), [], []) + assert diff > same + + +async def test_tendency_averages_similar_neighbours() -> None: + + entries = [ + make_entry( + id="e1", + rating=3, + acidity=2, + bitterness=0, + body=-1, + sweetness=1, + strength=0, + bean_dimensions_snapshot=_snap(varietal="Geisha", process="natural", roast_level="light"), + ), + make_entry( + id="e2", + rating=4, + acidity=1, + bitterness=0, + body=0, + sweetness=1, + strength=0, + bean_dimensions_snapshot=_snap(varietal="Geisha", process="natural", roast_level="light"), + ), + make_entry( + id="e3", + rating=4, + acidity=-2, + bitterness=2, + body=2, + sweetness=0, + strength=1, + bean_dimensions_snapshot=_snap(varietal="Robusta", process="washed", roast_level="dark"), + ), + ] + repo = AsyncMock() + repo.list.return_value = entries + query = PalateQuery(repo) + result = await query.tendency_for(BeanDimensions(varietal="Geisha", process="natural", roast_level="light")) + assert result.n >= 2 + assert result.tendency["acidity"] > 0.5 + assert result.confidence > 0.0 + + +async def test_tendency_empty_when_no_rated_history() -> None: + repo = AsyncMock() + repo.list.return_value = [] + result = await PalateQuery(repo).tendency_for(BeanDimensions(varietal="Geisha")) + assert result.n == 0 + assert result.confidence == 0.0 + assert result.tendency == {} From 86614c206c14081d4bbf3cddc0b56cb87203ce63 Mon Sep 17 00:00:00 2001 From: Leo Le Bleis Date: Sat, 13 Jun 2026 21:04:42 +0100 Subject: [PATCH 07/11] feat(journal): log_tasting + query_palate MCP tools, migrations, wiring --- src/brew/journal/dependencies.py | 6 ++ src/brew/journal/mcp.py | 148 +++++++++++++++++++++++++++++- src/brew/main.py | 37 +++++++- tests/journal/test_mcp.py | 2 +- tests/journal/test_mcp_tasting.py | 61 ++++++++++++ 5 files changed, 245 insertions(+), 9 deletions(-) create mode 100644 tests/journal/test_mcp_tasting.py diff --git a/src/brew/journal/dependencies.py b/src/brew/journal/dependencies.py index 7c177bb..9bc1e9d 100644 --- a/src/brew/journal/dependencies.py +++ b/src/brew/journal/dependencies.py @@ -1,6 +1,12 @@ +from brew.journal.palate import PalateQuery from brew.journal.service import JournalService def get_journal_service() -> JournalService: msg = "Must be overridden — wired in app lifespan" raise NotImplementedError(msg) + + +def get_palate_query() -> PalateQuery: + msg = "Must be overridden — wired in app lifespan" + raise NotImplementedError(msg) diff --git a/src/brew/journal/mcp.py b/src/brew/journal/mcp.py index 35a6287..b752457 100644 --- a/src/brew/journal/mcp.py +++ b/src/brew/journal/mcp.py @@ -1,11 +1,78 @@ +import json +from typing import Any + from fastmcp import FastMCP -from brew.errors import DomainError +from brew.bags.service import BagService +from brew.errors import DomainError, NotFoundError +from brew.journal.model.entry import TastingAxes +from brew.journal.palate import BeanDimensions, PalateQuery from brew.journal.service import JournalService -from brew.mcp_utils import domain_error_to_resource_payload, jsonify +from brew.mcp_utils import domain_error_to_resource_payload, domain_error_to_tool_error, jsonify + + +async def _resolve_log_tasting( # noqa: PLR0913 + service: JournalService, + bag_service: BagService, + entry_id: str | None, + bag_id: str | None, + acidity: int | None, + bitterness: int | None, + body: int | None, + sweetness: int | None, + strength: int | None, + flavor_tags: list[str] | None, + note_text: str | None, + rating: int | None, +) -> dict[str, Any]: + """Core logic for log_tasting — extracted to keep register_journal_mcp under the complexity cap.""" + # Resolve bag + bag = None + if bag_id: + bag = await bag_service.get(bag_id) + else: + bag = await bag_service.get_active() + + # Resolve target entry + if entry_id: + target = entry_id + else: + entries = await service.list(bag_id=bag.id if bag else None, limit=1) + if not entries: + resource_kind = "journal_entry" + resource_id = bag.id if bag else "active-bag" + raise NotFoundError.for_resource(resource_kind, resource_id) + target = entries[0].id + + # Build bean dimensions snapshot from bag + bean_dimensions_snapshot: dict[str, Any] | None = None + if bag is not None: + bean_dimensions_snapshot = { + "varietal": getattr(bag, "varietal", None), + "process": getattr(bag, "process", None), + "roast_level": getattr(bag, "roast_level", None), + "origin": getattr(bag, "origin", None), + "altitude_masl": getattr(bag, "altitude_masl", None), + } + + await service.record_tasting( + target, + axes=TastingAxes( + acidity=acidity, + bitterness=bitterness, + body=body, + sweetness=sweetness, + strength=strength, + ), + flavor_tags=flavor_tags or [], + note_text=note_text, + rating=rating, + bean_dimensions_snapshot=bean_dimensions_snapshot, + ) + return {"status": "logged", "entry_id": target} -def register_journal_mcp(mcp: FastMCP, service: JournalService) -> None: +def register_journal_mcp(mcp: FastMCP, service: JournalService, bag_service: BagService, palate: PalateQuery) -> None: @mcp.resource( "coffee://journal", description=( @@ -31,3 +98,78 @@ async def get_entry(entry_id: str) -> str: except DomainError as e: return domain_error_to_resource_payload(e) return jsonify(entry) + + @mcp.tool( + description=( + "Log how a brew tasted: signed axes (-2..+2, 0=balanced), flavor tags, note, 1-5 rating. " + "Omit entry_id to apply to the most recent brew of the active bag. " + "Snapshots the bag's bean dimensions for palate learning." + ), + ) + async def log_tasting( # noqa: PLR0913 + entry_id: str | None = None, + bag_id: str | None = None, + acidity: int | None = None, + bitterness: int | None = None, + body: int | None = None, + sweetness: int | None = None, + strength: int | None = None, + flavor_tags: list[str] | None = None, + note_text: str | None = None, + rating: int | None = None, + ) -> str: + try: + result = await _resolve_log_tasting( + service, + bag_service, + entry_id, + bag_id, + acidity, + bitterness, + body, + sweetness, + strength, + flavor_tags, + note_text, + rating, + ) + except DomainError as e: + raise domain_error_to_tool_error(e) from e + return jsonify(result) + + @mcp.tool( + description=( + "Predict how the user will likely perceive a bean from their tasting history of similar beans " + "(weighted nearest-neighbour). Returns mean axes, confidence (0-1), neighbour count, and " + "contributing brews. Call when building a new profile to pre-correct toward the user's palate." + ), + ) + async def query_palate( # noqa: PLR0913 + varietal: str | None = None, + process: str | None = None, + roast_level: str | None = None, + origin: str | None = None, + altitude_masl: int | None = None, + flavor_tags: list[str] | None = None, + ) -> str: + try: + tendency = await palate.tendency_for( + BeanDimensions( + varietal=varietal, + process=process, + roast_level=roast_level, + origin=origin, + altitude_masl=altitude_masl, + flavor_tags=flavor_tags or [], + ) + ) + except DomainError as e: + raise domain_error_to_tool_error(e) from e + return json.dumps( + { + "tendency": tendency.tendency, + "confidence": tendency.confidence, + "n": tendency.n, + "neighbours": tendency.neighbours, + } + ) diff --git a/src/brew/main.py b/src/brew/main.py index 6732dc3..fd7cc1b 100644 --- a/src/brew/main.py +++ b/src/brew/main.py @@ -28,7 +28,7 @@ from brew.bags.router import router as bags_router from brew.bags.schema import BAGS_SCHEMA from brew.bags.service import BagService -from brew.db import init_db, open_db +from brew.db import add_missing_columns, init_db, open_db from brew.dependencies import get_settings, require_api_key from brew.events.broadcaster import EventBroadcaster from brew.events.bus import EventBus @@ -47,7 +47,8 @@ from brew.events.subscribers.water_decrement import make_water_decrement_handler from brew.exception_handlers import register_exception_handlers from brew.health.router import router as health_router -from brew.journal.dependencies import get_journal_service +from brew.journal.dependencies import get_journal_service, get_palate_query +from brew.journal.palate import PalateQuery from brew.journal.repository import JournalSqliteRepository from brew.journal.router import router as journal_router from brew.journal.schema import JOURNAL_SCHEMA @@ -105,6 +106,7 @@ def _register_domain_mcp( water_service: WaterService, bag_service: BagService, journal_service: JournalService, + palate_query: PalateQuery, ) -> None: from brew.bags.mcp import register_bags_mcp # noqa: PLC0415 from brew.journal.mcp import register_journal_mcp # noqa: PLC0415 @@ -112,7 +114,7 @@ def _register_domain_mcp( register_water_mcp(_mcp_server, water_service) register_bags_mcp(_mcp_server, bag_service) - register_journal_mcp(_mcp_server, journal_service) + register_journal_mcp(_mcp_server, journal_service, bag_service, palate_query) @asynccontextmanager @@ -136,12 +138,36 @@ async def _app_lifespan(app: FastAPI) -> AsyncGenerator[None]: db_conn = await open_db(settings.database_path) await init_db(db_conn, [WATER_SCHEMA, BAGS_SCHEMA, JOURNAL_SCHEMA]) + await add_missing_columns( + db_conn, + "bags", + { + "varietal": "TEXT", + "process": "TEXT", + "altitude_masl": "INTEGER", + }, + ) + await add_missing_columns( + db_conn, + "journal_entries", + { + "acidity": "INTEGER", + "bitterness": "INTEGER", + "body": "INTEGER", + "sweetness": "INTEGER", + "strength": "INTEGER", + "flavor_tags": "TEXT NOT NULL DEFAULT '[]'", + "bean_dimensions_snapshot": "TEXT", + }, + ) bus = EventBus() broadcaster = EventBroadcaster() water_service = WaterService(repo=WaterSqliteRepository(conn=db_conn), bus=bus) bag_service = BagService(repo=BagSqliteRepository(conn=db_conn), bus=bus) - journal_service = JournalService(repo=JournalSqliteRepository(conn=db_conn), bus=bus) + journal_repo = JournalSqliteRepository(conn=db_conn) + journal_service = JournalService(repo=journal_repo, bus=bus) + palate_query = PalateQuery(journal_repo) _wire_event_subscribers(bus, broadcaster, journal_service, bag_service, water_service) poller_interval = float(os.getenv("FELLOW_POLLER_INTERVAL_SECONDS", "5.0")) @@ -157,13 +183,14 @@ async def _app_lifespan(app: FastAPI) -> AsyncGenerator[None]: get_water_service: lambda: water_service, get_bag_service: lambda: bag_service, get_journal_service: lambda: journal_service, + get_palate_query: lambda: palate_query, get_event_broadcaster: lambda: broadcaster, } ) if _mcp_enabled: _register_aiden_mcp(device_service, profile_service, schedule_service, brew_now_service) - _register_domain_mcp(water_service, bag_service, journal_service) + _register_domain_mcp(water_service, bag_service, journal_service, palate_query) try: yield diff --git a/tests/journal/test_mcp.py b/tests/journal/test_mcp.py index 0ef05b7..1134ea8 100644 --- a/tests/journal/test_mcp.py +++ b/tests/journal/test_mcp.py @@ -17,7 +17,7 @@ def mock_service() -> AsyncMock: @pytest.fixture def mcp(mock_service: AsyncMock) -> FastMCP: server = FastMCP("test") - register_journal_mcp(server, mock_service) + register_journal_mcp(server, mock_service, AsyncMock(), AsyncMock()) return server diff --git a/tests/journal/test_mcp_tasting.py b/tests/journal/test_mcp_tasting.py new file mode 100644 index 0000000..8906d14 --- /dev/null +++ b/tests/journal/test_mcp_tasting.py @@ -0,0 +1,61 @@ +import json +from unittest.mock import AsyncMock + +from fastmcp import FastMCP + +from brew.journal.mcp import register_journal_mcp +from brew.journal.palate import PalateTendency + + +async def test_query_palate_returns_tendency() -> None: + journal_service = AsyncMock() + bag_service = AsyncMock() + palate = AsyncMock() + palate.tendency_for.return_value = PalateTendency( + tendency={"acidity": 1.5}, + confidence=0.6, + n=3, + neighbours=[{"entry_id": "e1", "similarity": 0.9, "axes": {}, "rating": 3}], + ) + mcp = FastMCP("test") + register_journal_mcp(mcp, journal_service, bag_service, palate) + + result = await mcp.call_tool("query_palate", {"varietal": "Geisha", "process": "natural"}) + payload = json.loads(result.content[0].text) + assert payload["confidence"] == 0.6 + assert payload["tendency"]["acidity"] == 1.5 + + +async def test_log_tasting_snapshots_active_bag_dimensions() -> None: + journal_service = AsyncMock() + bag_service = AsyncMock() + bag = AsyncMock() + bag.id = "bag-1" + bag.varietal, bag.process, bag.roast_level, bag.origin, bag.altitude_masl = ( + "Geisha", + "natural", + "light", + "Huila", + 1650, + ) + bag_service.get_active.return_value = bag + latest = AsyncMock() + latest.id = "entry-9" + journal_service.list.return_value = [latest] + + mcp = FastMCP("test") + register_journal_mcp(mcp, journal_service, bag_service, AsyncMock()) + + await mcp.call_tool( + "log_tasting", + { + "acidity": 2, + "flavor_tags": ["floral"], + "note_text": "sharp", + "rating": 3, + }, + ) + journal_service.record_tasting.assert_awaited_once() + kwargs = journal_service.record_tasting.call_args.kwargs + assert kwargs["bean_dimensions_snapshot"]["varietal"] == "Geisha" + assert kwargs["flavor_tags"] == ["floral"] From c6d20486730259c98b36ca9eaf622555216b4fec Mon Sep 17 00:00:00 2001 From: Leo Le Bleis Date: Sat, 13 Jun 2026 21:06:35 +0100 Subject: [PATCH 08/11] feat(brew-log): add tasting capture + coaching skill (0.3.0) --- .claude-plugin/marketplace.json | 2 +- skills/brew-log/SKILL.md | 82 +++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 skills/brew-log/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index dbca8bc..ee9bc63 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "brew", "source": "./", "description": "Claude Code skill for brewing coffee on a Fellow Aiden via the brew MCP server — pairs with the MCP server in this repo.", - "version": "0.2.0" + "version": "0.3.0" } ] } diff --git a/skills/brew-log/SKILL.md b/skills/brew-log/SKILL.md new file mode 100644 index 0000000..187f3a7 --- /dev/null +++ b/skills/brew-log/SKILL.md @@ -0,0 +1,82 @@ +--- +name: brew-log +description: Use when the user wants to log how a brewed coffee tasted on the Fellow Aiden — they describe the cup ("too sour", "hollow", "great", "more bitter than I'd like") after drinking. Also triggers on `/brew-log`. +--- + +# `/brew-log` — log a tasting & coach the palate + +Turn *"I'm drinking it and it's a bit sharp"* into a structured, queryable tasting record. +You talk naturally; the skill parses your words into axes + flavor tags, stores both the +prose and the structure (via the MCP `log_tasting` tool), and optionally offers a recipe +tweak. Over time this feeds `/brew`'s palate pre-flight ("you tend to find Geishas sour"). + +## When to use + +- The user describes how a brewed cup tasted (after drinking). +- The user says `/brew-log`. +- A post-brew "how was it?" moment. + +## Flow + +1. **Identify the brew.** Read `coffee://journal` — the most recent entry for the active + bag is almost always the one. If it's ambiguous (multiple recent, or they mean an older + cup), ask which. +2. **Coach the tasting.** Nudge for a 1–5 rating if they haven't given one. Help them + articulate — offer relatable, *researched* calibration ("sharper than lemon, or + softer?"; "is the bitterness like dark chocolate or like burnt?"), grounded in the SCA + flavor wheel. **Keep it light and optional** — never force structure on someone who + just wants to say "it's good." +3. **Parse to axes + tags.** Map the prose to the five signed axes and the flavor-wheel tags. +4. **Persist.** Call `mcp__brew__log_tasting` with the axes, `flavor_tags`, `note_text` + (their words verbatim), and `rating`. Omit `entry_id` to apply to the active bag's most + recent brew — the tool snapshots the bag's bean dimensions automatically. **Always go + through `log_tasting`** — don't hand-roll a raw journal PATCH, or the bean-dimension + snapshot (which palate learning depends on) won't be written. +5. **Offer a fix (optional).** If the cup was off, propose an `update_profile` diff *for + this bag now* using the fault→lever table below. One change at a time; name it + ("bumping grind 4 → 3 for next time"). + +## Axis vocabulary (the parse target) + +Signed **−2 … +2**, where **0 = balanced/just right**: + +| Axis | −2 | +2 | +|---|---|---| +| `acidity` | flat / dull | sharp / sour | +| `bitterness` | none | bitter / harsh | +| `body` | thin / watery | heavy / syrupy | +| `sweetness` | low | high / candy-like | +| `strength` | weak | strong / intense | + +Only set the axes the user actually spoke to; leave the rest unset (don't invent). + +## Fault → lever (source of truth — Barista Hustle Coffee Compass) + +| Taste | Reading | Primary lever | Secondary | +|---|---|---|---| +| sour / thin (acidity +, sweetness −) | under-extracted | **grind finer** | ↑ bloom temp, ↑ pulse temp | +| bitter / dry (bitterness +) | over-extracted | **grind coarser** | ↓ pulse temp | +| weak / watery (strength −) | low strength | **tighten ratio** (16.5 → 16.0) | — | +| too strong (strength +) | high strength | **loosen ratio** (16.0 → 17.0) | — | +| balanced | locked | save as the bag's default | — | + +Extraction faults (sour/bitter) move **grind/temp**; strength faults move **ratio**. + +## Water (BWT magnesium-forward filter) + +If a cup reads **sharp / sour / thin**, fix it with a **weaker ratio (~1:17)** or by adding +buffer to the water — **never grind coarser "because of the water."** Magnesium-forward +water doesn't over-extract; sharpness is an alkalinity/ratio issue. (Grinding *finer* for a +genuinely under-extracted cup is still correct — that's extraction, not the water.) + +## Red flags + +- Grinding coarser for sharpness "because the water is magnesium-forward" → **stop**, that's + unfounded; weaken the ratio instead. +- Forcing the user to rate every axis → **stop**, capture only what they said; coaching is an + offer, not a quiz. +- Writing tasting data via a raw `PATCH /journal/{id}` instead of `log_tasting` → **stop**, + that skips the bean-dimension snapshot palate learning needs. +- Inventing axis values the user didn't express → **stop**, leave them unset. +- Stacking multiple recipe changes at once → **stop**, one lever per iteration so the next + cup is a clean signal. From 43299fcb4118c6b2b37215741d8d03493df2a7fa Mon Sep 17 00:00:00 2001 From: Leo Le Bleis Date: Sat, 13 Jun 2026 21:08:36 +0100 Subject: [PATCH 09/11] =?UTF-8?q?feat(brew):=20palate=20pre-flight=20?= =?UTF-8?q?=E2=80=94=20pre-correct=20recipes=20from=20tasting=20history=20?= =?UTF-8?q?(0.3.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude-plugin/marketplace.json | 2 +- skills/brew/SKILL.md | 29 ++++++++++++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ee9bc63..9207b6a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "brew", "source": "./", "description": "Claude Code skill for brewing coffee on a Fellow Aiden via the brew MCP server — pairs with the MCP server in this repo.", - "version": "0.3.0" + "version": "0.3.1" } ] } diff --git a/skills/brew/SKILL.md b/skills/brew/SKILL.md index aece8a2..034600b 100644 --- a/skills/brew/SKILL.md +++ b/skills/brew/SKILL.md @@ -25,17 +25,34 @@ settings, and the device shows the dose once you pick millilitres). ## Flow -1. **Identify** the bean (photo/text). +1. **Identify** the bean (photo/text), incl. dimensions: varietal, process, roast, origin. 2. **Check `coffee://profiles`** for an existing match — roaster + coffee-name substring (e.g. "Intermission" + "Spring Bloom"). User profiles live in the `Custom` folder. If one matches, **reuse it** and say so; skip the rest. Only create fresh if there's no match or the user asks for a new one. 3. **Clone a template drop** from `coffee://profiles` via the decision tree in `references/profile-heuristics.md`. -4. **Apply nudges**: light-natural/Geisha overrides (below) + roast-delta. -5. **Derive the two grind numbers** — single-serve and batch (below). -6. **`create_profile`** with `profile_type=0`, °C temps, and the grind-span title. -7. **Print the recipe card.** Stop. +4. **Palate pre-flight** — call `query_palate(varietal, process, roast_level, origin, + altitude_masl)` for the user's tasting history of *similar* beans (see below). +5. **Apply nudges**: light-natural/Geisha overrides (below) + roast-delta, **plus the + palate correction** from step 4 if its confidence is meaningful. +6. **Derive the two grind numbers** — single-serve and batch (below). +7. **`create_profile`** with `profile_type=0`, °C temps, and the grind-span title. +8. **Print the recipe card.** Stop. + +### Palate pre-flight (step 4) + +`query_palate` returns `{tendency, confidence, n, neighbours}` — `tendency` is the +similarity-weighted mean of the signed tasting axes (−2…+2) across the user's similar +past brews. If `n ≥ 2` and confidence is meaningful, translate the tendency into a +template nudge via the fault→lever mapping and **say why**: + +> *"Your last 3 Geisha naturals averaged sour (+1.5 acidity) — starting one grind finer +> and ratio 16.0 instead of the template's 16.5."* + +Lever directions: **acidity +** (trends sour) → grind finer / ratio tighter; **bitterness +** +(trends bitter) → grind coarser; **strength +/−** → ratio looser/tighter. If `n = 0` or +confidence is low, proceed from the template **silently** — never invent history. ## Non-negotiable MCP values @@ -206,6 +223,8 @@ This skill creates the profile and stops. Handled elsewhere: `brew_now`** as part of `/brew` → **stop**, all out of scope; this skill ends at the recipe card. - Skipping the `coffee://profiles` dedup check → **stop**, reuse before creating. +- About to finalize a profile without calling `query_palate` for a bean whose dimensions + you know → **stop**, check palate history first and pre-correct (step 4). - Inventing pulse temps / ratios from nothing → **stop**, clone a Fellow drop first. - Using the table's "light" grind (5 SS) for a Geisha / delicate natural → **stop**, those grind finer — Fellow's own call is ~4 SS. From c38a6ee897874b6870263df3ebbfb48581347b86 Mon Sep 17 00:00:00 2001 From: Leo Le Bleis Date: Sat, 13 Jun 2026 21:21:22 +0100 Subject: [PATCH 10/11] =?UTF-8?q?refactor(journal):=20address=20final=20re?= =?UTF-8?q?view=20=E2=80=94=20type=20scored=20properly,=20drop=20ty:ignore?= =?UTF-8?q?s,=20direct=20attr=20access,=20doc=20migration=20CHECK,=20fix?= =?UTF-8?q?=20import=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/brew/journal/mcp.py | 10 +++++----- src/brew/journal/palate.py | 7 ++++--- src/brew/main.py | 4 ++++ tests/journal/test_palate.py | 1 - 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/brew/journal/mcp.py b/src/brew/journal/mcp.py index b752457..6bed485 100644 --- a/src/brew/journal/mcp.py +++ b/src/brew/journal/mcp.py @@ -48,11 +48,11 @@ async def _resolve_log_tasting( # noqa: PLR0913 bean_dimensions_snapshot: dict[str, Any] | None = None if bag is not None: bean_dimensions_snapshot = { - "varietal": getattr(bag, "varietal", None), - "process": getattr(bag, "process", None), - "roast_level": getattr(bag, "roast_level", None), - "origin": getattr(bag, "origin", None), - "altitude_masl": getattr(bag, "altitude_masl", None), + "varietal": bag.varietal, + "process": bag.process, + "roast_level": bag.roast_level, + "origin": bag.origin, + "altitude_masl": bag.altitude_masl, } await service.record_tasting( diff --git a/src/brew/journal/palate.py b/src/brew/journal/palate.py index 3ba75a6..0ec3b67 100644 --- a/src/brew/journal/palate.py +++ b/src/brew/journal/palate.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: import builtins + from brew.journal.model.entry import JournalEntry from brew.journal.repository import JournalRepository _AXES = ("acidity", "bitterness", "body", "sweetness", "strength") @@ -106,7 +107,7 @@ def __init__(self, repo: JournalRepository) -> None: async def tendency_for(self, query: BeanDimensions, *, limit: int = 500) -> PalateTendency: entries = await self._repo.list(rating_min=1, limit=limit) - scored: list[tuple[float, object]] = [] + scored: list[tuple[float, JournalEntry]] = [] for e in entries: if e.bean_dimensions_snapshot is None: continue @@ -129,10 +130,10 @@ async def tendency_for(self, query: BeanDimensions, *, limit: int = 500) -> Pala confidence = round(min(1.0, total_sim / _FULL_CONFIDENCE_SIM), 2) neighbours = [ { - "entry_id": e.id, # ty: ignore[unresolved-attribute] + "entry_id": e.id, "similarity": round(sim, 2), "axes": {a: getattr(e, a) for a in _AXES}, - "rating": e.rating, # ty: ignore[unresolved-attribute] + "rating": e.rating, } for sim, e in sorted(scored, key=lambda x: x[0], reverse=True) ] diff --git a/src/brew/main.py b/src/brew/main.py index fd7cc1b..50daac5 100644 --- a/src/brew/main.py +++ b/src/brew/main.py @@ -147,6 +147,10 @@ async def _app_lifespan(app: FastAPI) -> AsyncGenerator[None]: "altitude_masl": "INTEGER", }, ) + # Migrated DBs get bare INTEGER axis columns (SQLite can't ADD COLUMN with a CHECK + # that references the table); the -2..+2 range is enforced by the API request models + # (Field ge=-2, le=2) and TastingAxes, not the migrated DB. Fresh installs keep the + # CHECK from JOURNAL_SCHEMA. await add_missing_columns( db_conn, "journal_entries", diff --git a/tests/journal/test_palate.py b/tests/journal/test_palate.py index cefb5be..5adb5b3 100644 --- a/tests/journal/test_palate.py +++ b/tests/journal/test_palate.py @@ -1,7 +1,6 @@ from unittest.mock import AsyncMock from brew.journal.palate import BeanDimensions, PalateQuery, dimension_distance - from tests.journal.conftest import make_entry From dfc5ce2b8826a84c64c4bc2bda25387119ad0508 Mon Sep 17 00:00:00 2001 From: Leo Le Bleis Date: Sat, 13 Jun 2026 21:26:33 +0100 Subject: [PATCH 11/11] =?UTF-8?q?refactor:=20simplify=20cleanups=20?= =?UTF-8?q?=E2=80=94=20data-driven=20bag=20update=20builder,=20jsonify=20i?= =?UTF-8?q?n=20query=5Fpalate,=20walrus=20in=20palate=20axis=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/brew/bags/repository.py | 52 +++++++++++++------------------------ src/brew/journal/mcp.py | 10 +------ src/brew/journal/palate.py | 2 +- 3 files changed, 20 insertions(+), 44 deletions(-) diff --git a/src/brew/bags/repository.py b/src/brew/bags/repository.py index c8a2d74..8f8a0f5 100644 --- a/src/brew/bags/repository.py +++ b/src/brew/bags/repository.py @@ -62,45 +62,29 @@ def _row_to_bag(row: aiosqlite.Row) -> Bag: def _build_update_clauses(update: BagUpdate) -> tuple[list[str], list[object]]: + # column -> value; None values are skipped. Keeps one flat builder (no per-group + # helper) while staying under the complexity cap. + columns: list[tuple[str, object | None]] = [ + ("name", update.name), + ("origin", update.origin), + ("roaster", update.roaster), + ("roast_date", update.roast_date.isoformat() if update.roast_date else None), + ("roast_level", update.roast_level), + ("profile_id", update.profile_id), + ("profile_snapshot", json.dumps(update.profile_snapshot) if update.profile_snapshot is not None else None), + ("varietal", update.varietal), + ("process", update.process), + ("altitude_masl", update.altitude_masl), + ] sets: list[str] = [] params: list[object] = [] - if update.name is not None: - sets.append("name = ?") - params.append(update.name) - if update.origin is not None: - sets.append("origin = ?") - params.append(update.origin) - if update.roaster is not None: - sets.append("roaster = ?") - params.append(update.roaster) - if update.roast_date is not None: - sets.append("roast_date = ?") - params.append(update.roast_date.isoformat()) - if update.roast_level is not None: - sets.append("roast_level = ?") - params.append(update.roast_level) - if update.profile_id is not None: - sets.append("profile_id = ?") - params.append(update.profile_id) - if update.profile_snapshot is not None: - sets.append("profile_snapshot = ?") - params.append(json.dumps(update.profile_snapshot)) - _append_dimension_clauses(update, sets, params) + for column, value in columns: + if value is not None: + sets.append(f"{column} = ?") + params.append(value) return sets, params -def _append_dimension_clauses(update: BagUpdate, sets: list[str], params: list[object]) -> None: - if update.varietal is not None: - sets.append("varietal = ?") - params.append(update.varietal) - if update.process is not None: - sets.append("process = ?") - params.append(update.process) - if update.altitude_masl is not None: - sets.append("altitude_masl = ?") - params.append(update.altitude_masl) - - class BagSqliteRepository: def __init__(self, conn: aiosqlite.Connection) -> None: self._conn = conn diff --git a/src/brew/journal/mcp.py b/src/brew/journal/mcp.py index 6bed485..30ea5a2 100644 --- a/src/brew/journal/mcp.py +++ b/src/brew/journal/mcp.py @@ -1,4 +1,3 @@ -import json from typing import Any from fastmcp import FastMCP @@ -165,11 +164,4 @@ async def query_palate( # noqa: PLR0913 ) except DomainError as e: raise domain_error_to_tool_error(e) from e - return json.dumps( - { - "tendency": tendency.tendency, - "confidence": tendency.confidence, - "n": tendency.n, - "neighbours": tendency.neighbours, - } - ) + return jsonify(tendency) diff --git a/src/brew/journal/palate.py b/src/brew/journal/palate.py index 0ec3b67..ec01231 100644 --- a/src/brew/journal/palate.py +++ b/src/brew/journal/palate.py @@ -122,7 +122,7 @@ async def tendency_for(self, query: BeanDimensions, *, limit: int = 500) -> Pala total_sim = sum(sim for sim, _ in scored) tendency: dict[str, float] = {} for axis in _AXES: - weighted = [(sim, getattr(e, axis)) for sim, e in scored if getattr(e, axis) is not None] + weighted = [(sim, val) for sim, e in scored if (val := getattr(e, axis)) is not None] axis_total = sum(sim for sim, _ in weighted) if axis_total > 0: tendency[axis] = round(sum(sim * val for sim, val in weighted) / axis_total, 2)