updated the van inventory to use the api

This commit is contained in:
2026-03-09 17:09:24 -04:00
parent a6fbbd245f
commit 6365dd8067
4 changed files with 100 additions and 124 deletions

View File

@@ -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 from __future__ import annotations
@@ -6,11 +6,11 @@ import json
import logging import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import httpx
from python.signal_bot.models import InventoryItem, InventoryUpdate from python.signal_bot.models import InventoryItem, InventoryUpdate
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path
from python.signal_bot.llm_client import LLMClient from python.signal_bot.llm_client import LLMClient
from python.signal_bot.models import SignalMessage from python.signal_bot.models import SignalMessage
from python.signal_bot.signal_client import SignalClient 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 You are an inventory assistant. Extract items from the input and return ONLY
a JSON array. Each element must have these fields: a JSON array. Each element must have these fields:
- "name": item name (string) - "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. - "category": category like "food", "tools", "supplies", etc.
- "notes": any extra detail (empty string if none) - "notes": any extra detail (empty string if none)
Example output: 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.\ 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] return [InventoryItem.model_validate(item) for item in items_data]
def load_inventory(path: Path) -> list[InventoryItem]: def _upsert_item(api_url: str, item: InventoryItem) -> None:
"""Load existing inventory from disk.""" """Create or update an item via the van_inventory API.
if not path.exists():
return []
data: list[dict[str, Any]] = json.loads(path.read_text())
return [InventoryItem.model_validate(item) for item in data]
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: match = next((e for e in existing if e["name"].lower() == item.name.lower()), None)
"""Save inventory to disk."""
path.parent.mkdir(parents=True, exist_ok=True) if match:
data = [item.model_dump() for item in items] new_qty = match["quantity"] + item.quantity
path.write_text(json.dumps(data, indent=2) + "\n") 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( def handle_inventory_update(
message: SignalMessage, message: SignalMessage,
signal: SignalClient, signal: SignalClient,
llm: LLMClient, llm: LLMClient,
inventory_path: Path, api_url: str,
) -> InventoryUpdate: ) -> InventoryUpdate:
"""Process an inventory update from a Signal message. """Process an inventory update from a Signal message.
Accepts either an image (receipt photo) or text list. 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: try:
if message.attachments: if message.attachments:
@@ -94,9 +111,9 @@ def handle_inventory_update(
return InventoryUpdate() return InventoryUpdate()
new_items = parse_llm_response(raw_response) new_items = parse_llm_response(raw_response)
existing = load_inventory(inventory_path)
merged = _merge_items(existing, new_items) for item in new_items:
save_inventory(inventory_path, merged) _upsert_item(api_url, item)
summary = _format_summary(new_items) summary = _format_summary(new_items)
signal.reply(message, f"Inventory updated with {len(new_items)} item(s):\n{summary}") signal.reply(message, f"Inventory updated with {len(new_items)} item(s):\n{summary}")
@@ -109,26 +126,7 @@ def handle_inventory_update(
return InventoryUpdate() 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: def _format_summary(items: list[InventoryItem]) -> str:
"""Format items into a readable summary.""" """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) return "\n".join(lines)

View File

@@ -3,8 +3,8 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from os import getenv
import time import time
from pathlib import Path
from typing import Annotated from typing import Annotated
import typer import typer
@@ -35,9 +35,9 @@ def help_action(
message: SignalMessage, message: SignalMessage,
_llm: LLMClient, _llm: LLMClient,
_registry: DeviceRegistry, _registry: DeviceRegistry,
_inventory_path: Path, _config: BotConfig,
_cmd: str, _cmd: str,
) -> str: ) -> None:
"""Return the help text for the bot.""" """Return the help text for the bot."""
signal.reply(message, HELP_TEXT) signal.reply(message, HELP_TEXT)
@@ -47,9 +47,9 @@ def status_action(
message: SignalMessage, message: SignalMessage,
llm: LLMClient, llm: LLMClient,
registry: DeviceRegistry, registry: DeviceRegistry,
_inventory_path: Path, _config: BotConfig,
_cmd: str, _cmd: str,
) -> str: ) -> None:
"""Return the status of the bot.""" """Return the status of the bot."""
models = llm.list_models() models = llm.list_models()
model_list = ", ".join(models[:10]) model_list = ", ".join(models[:10])
@@ -65,9 +65,9 @@ def unknown_action(
message: SignalMessage, message: SignalMessage,
_llm: LLMClient, _llm: LLMClient,
_registry: DeviceRegistry, _registry: DeviceRegistry,
_inventory_path: Path, _config: BotConfig,
cmd: str, cmd: str,
) -> str: ) -> None:
"""Return an error message for an unknown command.""" """Return an error message for an unknown command."""
signal.reply(message, f"Unknown command: {cmd}\n\n{HELP_TEXT}") signal.reply(message, f"Unknown command: {cmd}\n\n{HELP_TEXT}")
@@ -77,11 +77,11 @@ def inventory_action(
message: SignalMessage, message: SignalMessage,
llm: LLMClient, llm: LLMClient,
_registry: DeviceRegistry, _registry: DeviceRegistry,
inventory_path: Path, config: BotConfig,
_cmd: str, _cmd: str,
) -> str: ) -> None:
"""Process an inventory update.""" """Process an inventory update."""
handle_inventory_update(message, signal, llm, inventory_path) handle_inventory_update(message, signal, llm, config.inventory_api_url)
def dispatch( def dispatch(
@@ -89,7 +89,6 @@ def dispatch(
signal: SignalClient, signal: SignalClient,
llm: LLMClient, llm: LLMClient,
registry: DeviceRegistry, registry: DeviceRegistry,
inventory_path: Path,
config: BotConfig, config: BotConfig,
) -> None: ) -> None:
"""Route an incoming message to the right command handler.""" """Route an incoming message to the right command handler."""
@@ -104,7 +103,6 @@ def dispatch(
prefix = config.cmd_prefix prefix = config.cmd_prefix
if not text.startswith(prefix) and not message.attachments: if not text.startswith(prefix) and not message.attachments:
return return
text.startswith(prefix)
cmd = text.lstrip(prefix).split()[0].lower() if text.startswith(prefix) else "" cmd = text.lstrip(prefix).split()[0].lower() if text.startswith(prefix) else ""
commands = { commands = {
@@ -114,7 +112,7 @@ def dispatch(
} }
action = commands.get(cmd, unknown_action) action = commands.get(cmd, unknown_action)
action(signal, message, llm, registry, inventory_path, cmd) action(signal, message, llm, registry, config, cmd)
def run_loop( def run_loop(
@@ -124,7 +122,6 @@ def run_loop(
registry: DeviceRegistry, registry: DeviceRegistry,
) -> None: ) -> None:
"""Listen for messages via WebSocket, reconnecting on failure.""" """Listen for messages via WebSocket, reconnecting on failure."""
inventory_path = Path(config.inventory_file)
logger.info("Bot started — listening via WebSocket") logger.info("Bot started — listening via WebSocket")
retries = 0 retries = 0
@@ -136,7 +133,7 @@ def run_loop(
logger.info(f"Message from {message.source}: {message.message[:80]}") logger.info(f"Message from {message.source}: {message.message[:80]}")
safety_number = signal.get_safety_number(message.source) safety_number = signal.get_safety_number(message.source)
registry.record_contact(message.source, safety_number) registry.record_contact(message.source, safety_number)
dispatch(message, signal, llm, registry, inventory_path, config) dispatch(message, signal, llm, registry, config)
retries = 0 retries = 0
delay = config.reconnect_delay delay = config.reconnect_delay
except Exception: except Exception:
@@ -148,25 +145,36 @@ def run_loop(
logger.critical("Max retries exceeded, shutting down") logger.critical("Max retries exceeded, shutting down")
def main( def main(log_level: Annotated[str, typer.Option()] = "INFO") -> None:
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:
"""Run the Signal command and control bot.""" """Run the Signal command and control bot."""
configure_logger(log_level) 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( config = BotConfig(
signal_api_url=signal_api_url, signal_api_url=signal_api_url,
phone_number=phone_number, phone_number=phone_number,
inventory_file=inventory_file, inventory_api_url=inventory_api_url,
) )
engine = get_postgres_engine(name="RICHIE") 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 ( with (
SignalClient(config.signal_api_url, config.phone_number) as signal, SignalClient(config.signal_api_url, config.phone_number) as signal,

View File

@@ -49,7 +49,8 @@ class InventoryItem(BaseModel):
"""An item in the van inventory.""" """An item in the van inventory."""
name: str name: str
quantity: int = 1 quantity: float = 1
unit: str = "each"
category: str = "" category: str = ""
notes: str = "" notes: str = ""
@@ -67,7 +68,7 @@ class BotConfig(BaseModel):
signal_api_url: str signal_api_url: str
phone_number: str phone_number: str
inventory_file: str = "van_inventory.json" inventory_api_url: str
cmd_prefix: str = "!" cmd_prefix: str = "!"
reconnect_delay: int = 5 reconnect_delay: int = 5
max_reconnect_delay: int = 300 max_reconnect_delay: int = 300

View File

@@ -9,15 +9,13 @@ import pytest
from python.signal_bot.commands.inventory import ( from python.signal_bot.commands.inventory import (
_format_summary, _format_summary,
_merge_items,
load_inventory,
parse_llm_response, parse_llm_response,
save_inventory,
) )
from python.signal_bot.device_registry import DeviceRegistry from python.signal_bot.device_registry import DeviceRegistry
from python.signal_bot.llm_client import LLMClient from python.signal_bot.llm_client import LLMClient
from python.signal_bot.main import dispatch from python.signal_bot.main import dispatch
from python.signal_bot.models import ( from python.signal_bot.models import (
BotConfig,
InventoryItem, InventoryItem,
SignalMessage, SignalMessage,
TrustLevel, TrustLevel,
@@ -40,19 +38,21 @@ class TestModels:
def test_inventory_item_defaults(self): def test_inventory_item_defaults(self):
item = InventoryItem(name="wrench") item = InventoryItem(name="wrench")
assert item.quantity == 1 assert item.quantity == 1
assert item.unit == "each"
assert item.category == "" assert item.category == ""
class TestInventoryParsing: class TestInventoryParsing:
def test_parse_llm_response_basic(self): 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) items = parse_llm_response(raw)
assert len(items) == 1 assert len(items) == 1
assert items[0].name == "water" assert items[0].name == "water"
assert items[0].quantity == 6 assert items[0].quantity == 6
assert items[0].unit == "gallon"
def test_parse_llm_response_with_code_fence(self): 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) items = parse_llm_response(raw)
assert len(items) == 1 assert len(items) == 1
assert items[0].name == "tape" assert items[0].name == "tape"
@@ -61,43 +61,12 @@ class TestInventoryParsing:
with pytest.raises(json.JSONDecodeError): with pytest.raises(json.JSONDecodeError):
parse_llm_response("not json at all") 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): 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) summary = _format_summary(items)
assert "water" in summary assert "water" in summary
assert "x6" in summary assert "x6" in summary
assert "gallon" 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) == []
class TestDeviceRegistry: class TestDeviceRegistry:
@@ -160,45 +129,45 @@ class TestDispatch:
@pytest.fixture @pytest.fixture
def registry_mock(self): def registry_mock(self):
mock = MagicMock(spec=DeviceRegistry) mock = MagicMock(spec=DeviceRegistry)
mock.is_blocked.return_value = False
mock.is_verified.return_value = True mock.is_verified.return_value = True
mock.has_safety_number.return_value = True
return mock return mock
def test_blocked_device_ignored(self, signal_mock, llm_mock, registry_mock, tmp_path): @pytest.fixture
registry_mock.is_blocked.return_value = True def config(self):
msg = SignalMessage(source="+1234", timestamp=0, message="!help") return BotConfig(
dispatch(msg, signal_mock, llm_mock, registry_mock, tmp_path / "inv.json") signal_api_url="http://localhost:8080",
signal_mock.reply.assert_not_called() 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 registry_mock.is_verified.return_value = False
msg = SignalMessage(source="+1234", timestamp=0, message="!help") 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() signal_mock.reply.assert_not_called()
assert "not verified" in signal_mock.reply.call_args[0][1]
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") 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() signal_mock.reply.assert_called_once()
assert "Available commands" in signal_mock.reply.call_args[0][1] 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") 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() signal_mock.reply.assert_called_once()
assert "Unknown command" in signal_mock.reply.call_args[0][1] 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") 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() 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.list_models.return_value = ["model1", "model2"]
llm_mock.model = "test:7b" llm_mock.model = "test:7b"
registry_mock.list_devices.return_value = [] registry_mock.list_devices.return_value = []
msg = SignalMessage(source="+1234", timestamp=0, message="!status") 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() signal_mock.reply.assert_called_once()
assert "Bot online" in signal_mock.reply.call_args[0][1] assert "Bot online" in signal_mock.reply.call_args[0][1]