mirror of
https://github.com/RichieCahill/dotfiles.git
synced 2026-04-17 04:58:19 -04:00
updated the van inventory to use the api
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user