From 6365dd8067b32c8d8853a7bf1b446bf2470f2188 Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Mon, 9 Mar 2026 17:09:24 -0400 Subject: [PATCH] updated the van inventory to use the api --- python/signal_bot/commands/inventory.py | 80 ++++++++++++------------ python/signal_bot/main.py | 58 ++++++++++-------- python/signal_bot/models.py | 5 +- tests/test_signal_bot.py | 81 ++++++++----------------- 4 files changed, 100 insertions(+), 124 deletions(-) diff --git a/python/signal_bot/commands/inventory.py b/python/signal_bot/commands/inventory.py index 61b6a8c..f0ca9b3 100644 --- a/python/signal_bot/commands/inventory.py +++ b/python/signal_bot/commands/inventory.py @@ -1,4 +1,4 @@ -"""Van inventory command — parse receipts and item lists via LLM.""" +"""Van inventory command — parse receipts and item lists via LLM, push to API.""" from __future__ import annotations @@ -6,11 +6,11 @@ import json import logging from typing import TYPE_CHECKING, Any +import httpx + from python.signal_bot.models import InventoryItem, InventoryUpdate if TYPE_CHECKING: - from pathlib import Path - from python.signal_bot.llm_client import LLMClient from python.signal_bot.models import SignalMessage from python.signal_bot.signal_client import SignalClient @@ -21,12 +21,13 @@ SYSTEM_PROMPT = """\ You are an inventory assistant. Extract items from the input and return ONLY a JSON array. Each element must have these fields: - "name": item name (string) - - "quantity": integer count (default 1) + - "quantity": numeric count or amount (default 1) + - "unit": unit of measure (e.g. "each", "lb", "oz", "gallon", "bag", "box") - "category": category like "food", "tools", "supplies", etc. - "notes": any extra detail (empty string if none) Example output: -[{"name": "water bottles", "quantity": 6, "category": "supplies", "notes": "1 gallon each"}] +[{"name": "water bottles", "quantity": 6, "unit": "gallon", "category": "supplies", "notes": "1 gallon each"}] Return ONLY the JSON array, no other text.\ """ @@ -48,31 +49,47 @@ def parse_llm_response(raw: str) -> list[InventoryItem]: return [InventoryItem.model_validate(item) for item in items_data] -def load_inventory(path: Path) -> list[InventoryItem]: - """Load existing inventory from disk.""" - if not path.exists(): - return [] - data: list[dict[str, Any]] = json.loads(path.read_text()) - return [InventoryItem.model_validate(item) for item in data] +def _upsert_item(api_url: str, item: InventoryItem) -> None: + """Create or update an item via the van_inventory API. + Fetches existing items, and if one with the same name exists, + patches its quantity (summing). Otherwise creates a new item. + """ + base = api_url.rstrip("/") + response = httpx.get(f"{base}/api/items", timeout=10) + response.raise_for_status() + existing: list[dict[str, Any]] = response.json() -def save_inventory(path: Path, items: list[InventoryItem]) -> None: - """Save inventory to disk.""" - path.parent.mkdir(parents=True, exist_ok=True) - data = [item.model_dump() for item in items] - path.write_text(json.dumps(data, indent=2) + "\n") + match = next((e for e in existing if e["name"].lower() == item.name.lower()), None) + + if match: + new_qty = match["quantity"] + item.quantity + patch = {"quantity": new_qty} + if item.category: + patch["category"] = item.category + response = httpx.patch(f"{base}/api/items/{match['id']}", json=patch, timeout=10) + response.raise_for_status() + return + payload = { + "name": item.name, + "quantity": item.quantity, + "unit": item.unit, + "category": item.category or None, + } + response = httpx.post(f"{base}/api/items", json=payload, timeout=10) + response.raise_for_status() def handle_inventory_update( message: SignalMessage, signal: SignalClient, llm: LLMClient, - inventory_path: Path, + api_url: str, ) -> InventoryUpdate: """Process an inventory update from a Signal message. Accepts either an image (receipt photo) or text list. - Uses the LLM to extract structured items, then merges into inventory. + Uses the LLM to extract structured items, then pushes to the van_inventory API. """ try: if message.attachments: @@ -94,9 +111,9 @@ def handle_inventory_update( return InventoryUpdate() new_items = parse_llm_response(raw_response) - existing = load_inventory(inventory_path) - merged = _merge_items(existing, new_items) - save_inventory(inventory_path, merged) + + for item in new_items: + _upsert_item(api_url, item) summary = _format_summary(new_items) signal.reply(message, f"Inventory updated with {len(new_items)} item(s):\n{summary}") @@ -109,26 +126,7 @@ def handle_inventory_update( return InventoryUpdate() -def _merge_items(existing: list[InventoryItem], new: list[InventoryItem]) -> list[InventoryItem]: - """Merge new items into existing inventory, summing quantities for matches.""" - by_name: dict[str, InventoryItem] = {item.name.lower(): item for item in existing} - for item in new: - key = item.name.lower() - if key in by_name: - current = by_name[key] - by_name[key] = current.model_copy( - update={ - "quantity": current.quantity + item.quantity, - "category": item.category or current.category, - "notes": item.notes or current.notes, - }, - ) - else: - by_name[key] = item - return list(by_name.values()) - - def _format_summary(items: list[InventoryItem]) -> str: """Format items into a readable summary.""" - lines = [f" - {item.name} x{item.quantity} [{item.category}]" for item in items] + lines = [f" - {item.name} x{item.quantity} {item.unit} [{item.category}]" for item in items] return "\n".join(lines) diff --git a/python/signal_bot/main.py b/python/signal_bot/main.py index 4a6cdf7..f8b67d0 100644 --- a/python/signal_bot/main.py +++ b/python/signal_bot/main.py @@ -3,8 +3,8 @@ from __future__ import annotations import logging +from os import getenv import time -from pathlib import Path from typing import Annotated import typer @@ -35,9 +35,9 @@ def help_action( message: SignalMessage, _llm: LLMClient, _registry: DeviceRegistry, - _inventory_path: Path, + _config: BotConfig, _cmd: str, -) -> str: +) -> None: """Return the help text for the bot.""" signal.reply(message, HELP_TEXT) @@ -47,9 +47,9 @@ def status_action( message: SignalMessage, llm: LLMClient, registry: DeviceRegistry, - _inventory_path: Path, + _config: BotConfig, _cmd: str, -) -> str: +) -> None: """Return the status of the bot.""" models = llm.list_models() model_list = ", ".join(models[:10]) @@ -65,9 +65,9 @@ def unknown_action( message: SignalMessage, _llm: LLMClient, _registry: DeviceRegistry, - _inventory_path: Path, + _config: BotConfig, cmd: str, -) -> str: +) -> None: """Return an error message for an unknown command.""" signal.reply(message, f"Unknown command: {cmd}\n\n{HELP_TEXT}") @@ -77,11 +77,11 @@ def inventory_action( message: SignalMessage, llm: LLMClient, _registry: DeviceRegistry, - inventory_path: Path, + config: BotConfig, _cmd: str, -) -> str: +) -> None: """Process an inventory update.""" - handle_inventory_update(message, signal, llm, inventory_path) + handle_inventory_update(message, signal, llm, config.inventory_api_url) def dispatch( @@ -89,7 +89,6 @@ def dispatch( signal: SignalClient, llm: LLMClient, registry: DeviceRegistry, - inventory_path: Path, config: BotConfig, ) -> None: """Route an incoming message to the right command handler.""" @@ -104,7 +103,6 @@ def dispatch( prefix = config.cmd_prefix if not text.startswith(prefix) and not message.attachments: return - text.startswith(prefix) cmd = text.lstrip(prefix).split()[0].lower() if text.startswith(prefix) else "" commands = { @@ -114,7 +112,7 @@ def dispatch( } action = commands.get(cmd, unknown_action) - action(signal, message, llm, registry, inventory_path, cmd) + action(signal, message, llm, registry, config, cmd) def run_loop( @@ -124,7 +122,6 @@ def run_loop( registry: DeviceRegistry, ) -> None: """Listen for messages via WebSocket, reconnecting on failure.""" - inventory_path = Path(config.inventory_file) logger.info("Bot started — listening via WebSocket") retries = 0 @@ -136,7 +133,7 @@ def run_loop( logger.info(f"Message from {message.source}: {message.message[:80]}") safety_number = signal.get_safety_number(message.source) registry.record_contact(message.source, safety_number) - dispatch(message, signal, llm, registry, inventory_path, config) + dispatch(message, signal, llm, registry, config) retries = 0 delay = config.reconnect_delay except Exception: @@ -148,25 +145,36 @@ def run_loop( logger.critical("Max retries exceeded, shutting down") -def main( - signal_api_url: Annotated[str, typer.Option(envvar="SIGNAL_API_URL")], - phone_number: Annotated[str, typer.Option(envvar="SIGNAL_PHONE_NUMBER")], - llm_host: Annotated[str, typer.Option(envvar="LLM_HOST")], - llm_model: Annotated[str, typer.Option(envvar="LLM_MODEL")] = "qwen3-vl:32b", - llm_port: Annotated[int, typer.Option(envvar="LLM_PORT")] = 11434, - inventory_file: Annotated[str, typer.Option(envvar="INVENTORY_FILE")] = "/var/lib/signal-bot/van_inventory.json", - log_level: Annotated[str, typer.Option()] = "INFO", -) -> None: +def main(log_level: Annotated[str, typer.Option()] = "INFO") -> None: """Run the Signal command and control bot.""" configure_logger(log_level) + signal_api_url = getenv("SIGNAL_API_URL") + phone_number = getenv("SIGNAL_PHONE_NUMBER") + inventory_api_url = getenv("INVENTORY_API_URL") + + if signal_api_url is None: + error = "SIGNAL_API_URL environment variable not set" + raise ValueError(error) + if phone_number is None: + error = "SIGNAL_PHONE_NUMBER environment variable not set" + raise ValueError(error) + if inventory_api_url is None: + error = "INVENTORY_API_URL environment variable not set" + raise ValueError(error) config = BotConfig( signal_api_url=signal_api_url, phone_number=phone_number, - inventory_file=inventory_file, + inventory_api_url=inventory_api_url, ) engine = get_postgres_engine(name="RICHIE") + llm_host = getenv("LLM_HOST") + llm_model = getenv("LLM_MODEL", "qwen3-vl:32b") + llm_port = int(getenv("LLM_PORT", "11434")) + if llm_host is None: + error = "LLM_HOST environment variable not set" + raise ValueError(error) with ( SignalClient(config.signal_api_url, config.phone_number) as signal, diff --git a/python/signal_bot/models.py b/python/signal_bot/models.py index c58238a..1c78242 100644 --- a/python/signal_bot/models.py +++ b/python/signal_bot/models.py @@ -49,7 +49,8 @@ class InventoryItem(BaseModel): """An item in the van inventory.""" name: str - quantity: int = 1 + quantity: float = 1 + unit: str = "each" category: str = "" notes: str = "" @@ -67,7 +68,7 @@ class BotConfig(BaseModel): signal_api_url: str phone_number: str - inventory_file: str = "van_inventory.json" + inventory_api_url: str cmd_prefix: str = "!" reconnect_delay: int = 5 max_reconnect_delay: int = 300 diff --git a/tests/test_signal_bot.py b/tests/test_signal_bot.py index 5ab4b2f..ce6581c 100644 --- a/tests/test_signal_bot.py +++ b/tests/test_signal_bot.py @@ -9,15 +9,13 @@ import pytest from python.signal_bot.commands.inventory import ( _format_summary, - _merge_items, - load_inventory, parse_llm_response, - save_inventory, ) from python.signal_bot.device_registry import DeviceRegistry from python.signal_bot.llm_client import LLMClient from python.signal_bot.main import dispatch from python.signal_bot.models import ( + BotConfig, InventoryItem, SignalMessage, TrustLevel, @@ -40,19 +38,21 @@ class TestModels: def test_inventory_item_defaults(self): item = InventoryItem(name="wrench") assert item.quantity == 1 + assert item.unit == "each" assert item.category == "" class TestInventoryParsing: def test_parse_llm_response_basic(self): - raw = '[{"name": "water", "quantity": 6, "category": "supplies", "notes": ""}]' + raw = '[{"name": "water", "quantity": 6, "unit": "gallon", "category": "supplies", "notes": ""}]' items = parse_llm_response(raw) assert len(items) == 1 assert items[0].name == "water" assert items[0].quantity == 6 + assert items[0].unit == "gallon" def test_parse_llm_response_with_code_fence(self): - raw = '```json\n[{"name": "tape", "quantity": 1, "category": "tools", "notes": ""}]\n```' + raw = '```json\n[{"name": "tape", "quantity": 1, "unit": "each", "category": "tools", "notes": ""}]\n```' items = parse_llm_response(raw) assert len(items) == 1 assert items[0].name == "tape" @@ -61,43 +61,12 @@ class TestInventoryParsing: with pytest.raises(json.JSONDecodeError): parse_llm_response("not json at all") - def test_merge_items_new(self): - existing = [InventoryItem(name="tape", quantity=2, category="tools")] - new = [InventoryItem(name="rope", quantity=1, category="supplies")] - merged = _merge_items(existing, new) - assert len(merged) == 2 - - def test_merge_items_duplicate_sums_quantity(self): - existing = [InventoryItem(name="tape", quantity=2, category="tools")] - new = [InventoryItem(name="tape", quantity=3, category="tools")] - merged = _merge_items(existing, new) - assert len(merged) == 1 - assert merged[0].quantity == 5 - - def test_merge_items_case_insensitive(self): - existing = [InventoryItem(name="Tape", quantity=1)] - new = [InventoryItem(name="tape", quantity=2)] - merged = _merge_items(existing, new) - assert len(merged) == 1 - assert merged[0].quantity == 3 - def test_format_summary(self): - items = [InventoryItem(name="water", quantity=6, category="supplies")] + items = [InventoryItem(name="water", quantity=6, unit="gallon", category="supplies")] summary = _format_summary(items) assert "water" in summary assert "x6" in summary - - def test_save_and_load_inventory(self, tmp_path): - path = tmp_path / "inventory.json" - items = [InventoryItem(name="wrench", quantity=1, category="tools")] - save_inventory(path, items) - loaded = load_inventory(path) - assert len(loaded) == 1 - assert loaded[0].name == "wrench" - - def test_load_inventory_missing_file(self, tmp_path): - path = tmp_path / "does_not_exist.json" - assert load_inventory(path) == [] + assert "gallon" in summary class TestDeviceRegistry: @@ -160,45 +129,45 @@ class TestDispatch: @pytest.fixture def registry_mock(self): mock = MagicMock(spec=DeviceRegistry) - mock.is_blocked.return_value = False mock.is_verified.return_value = True + mock.has_safety_number.return_value = True return mock - def test_blocked_device_ignored(self, signal_mock, llm_mock, registry_mock, tmp_path): - registry_mock.is_blocked.return_value = True - msg = SignalMessage(source="+1234", timestamp=0, message="!help") - dispatch(msg, signal_mock, llm_mock, registry_mock, tmp_path / "inv.json") - signal_mock.reply.assert_not_called() + @pytest.fixture + def config(self): + return BotConfig( + signal_api_url="http://localhost:8080", + phone_number="+1234567890", + ) - def test_unverified_device_gets_warning(self, signal_mock, llm_mock, registry_mock, tmp_path): + def test_unverified_device_ignored(self, signal_mock, llm_mock, registry_mock, config): registry_mock.is_verified.return_value = False msg = SignalMessage(source="+1234", timestamp=0, message="!help") - dispatch(msg, signal_mock, llm_mock, registry_mock, tmp_path / "inv.json") - signal_mock.reply.assert_called_once() - assert "not verified" in signal_mock.reply.call_args[0][1] + dispatch(msg, signal_mock, llm_mock, registry_mock, config) + signal_mock.reply.assert_not_called() - def test_help_command(self, signal_mock, llm_mock, registry_mock, tmp_path): + def test_help_command(self, signal_mock, llm_mock, registry_mock, config): msg = SignalMessage(source="+1234", timestamp=0, message="!help") - dispatch(msg, signal_mock, llm_mock, registry_mock, tmp_path / "inv.json") + dispatch(msg, signal_mock, llm_mock, registry_mock, config) signal_mock.reply.assert_called_once() assert "Available commands" in signal_mock.reply.call_args[0][1] - def test_unknown_command(self, signal_mock, llm_mock, registry_mock, tmp_path): + def test_unknown_command(self, signal_mock, llm_mock, registry_mock, config): msg = SignalMessage(source="+1234", timestamp=0, message="!foobar") - dispatch(msg, signal_mock, llm_mock, registry_mock, tmp_path / "inv.json") + dispatch(msg, signal_mock, llm_mock, registry_mock, config) signal_mock.reply.assert_called_once() assert "Unknown command" in signal_mock.reply.call_args[0][1] - def test_non_command_message_ignored(self, signal_mock, llm_mock, registry_mock, tmp_path): + def test_non_command_message_ignored(self, signal_mock, llm_mock, registry_mock, config): msg = SignalMessage(source="+1234", timestamp=0, message="hello there") - dispatch(msg, signal_mock, llm_mock, registry_mock, tmp_path / "inv.json") + dispatch(msg, signal_mock, llm_mock, registry_mock, config) signal_mock.reply.assert_not_called() - def test_status_command(self, signal_mock, llm_mock, registry_mock, tmp_path): + def test_status_command(self, signal_mock, llm_mock, registry_mock, config): llm_mock.list_models.return_value = ["model1", "model2"] llm_mock.model = "test:7b" registry_mock.list_devices.return_value = [] msg = SignalMessage(source="+1234", timestamp=0, message="!status") - dispatch(msg, signal_mock, llm_mock, registry_mock, tmp_path / "inv.json") + dispatch(msg, signal_mock, llm_mock, registry_mock, config) signal_mock.reply.assert_called_once() assert "Bot online" in signal_mock.reply.call_args[0][1]