From 254fc482dbf4e9773c1294f350d530fb727e65bf Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 14 Apr 2026 10:40:20 -0700 Subject: [PATCH 01/13] feat: add ebay_categories migration for LLM query builder category cache --- app/db/migrations/011_ebay_categories.sql | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 app/db/migrations/011_ebay_categories.sql diff --git a/app/db/migrations/011_ebay_categories.sql b/app/db/migrations/011_ebay_categories.sql new file mode 100644 index 0000000..84ac6f4 --- /dev/null +++ b/app/db/migrations/011_ebay_categories.sql @@ -0,0 +1,16 @@ +-- app/db/migrations/011_ebay_categories.sql +-- eBay category leaf node cache. Refreshed weekly via EbayCategoryCache.refresh(). +-- Seeded with a small bootstrap table when no eBay API credentials are configured. +-- MIT License + +CREATE TABLE IF NOT EXISTS ebay_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + full_path TEXT NOT NULL, -- "Consumer Electronics > ... > Leaf Name" + is_leaf INTEGER NOT NULL DEFAULT 1, -- SQLite stores bool as int + refreshed_at TEXT NOT NULL -- ISO8601 timestamp +); + +CREATE INDEX IF NOT EXISTS idx_ebay_cat_name + ON ebay_categories (name); From 099943b50b5f7ee3963181259c13fbcaa0a40228 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 14 Apr 2026 10:49:17 -0700 Subject: [PATCH 02/13] feat: EbayCategoryCache init, is_stale, bootstrap seed --- app/platforms/ebay/categories.py | 78 ++++++++++++++++++++++++++++++++ tests/test_ebay_categories.py | 58 ++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 app/platforms/ebay/categories.py create mode 100644 tests/test_ebay_categories.py diff --git a/app/platforms/ebay/categories.py b/app/platforms/ebay/categories.py new file mode 100644 index 0000000..d02896d --- /dev/null +++ b/app/platforms/ebay/categories.py @@ -0,0 +1,78 @@ +# app/platforms/ebay/categories.py +# MIT License +"""eBay category cache — fetches leaf categories from the Taxonomy API and stores them +in the local SQLite DB for injection into LLM query-builder prompts. + +Refreshed weekly. Falls back to a hardcoded bootstrap table when no eBay API +credentials are configured (scraper-only users still get usable category hints). +""" +from __future__ import annotations + +import logging +import sqlite3 +from datetime import datetime, timedelta, timezone +from typing import Optional + +log = logging.getLogger(__name__) + +# Bootstrap table — common categories for self-hosters without eBay API credentials. +# category_id values are stable eBay leaf IDs (US marketplace, as of 2026). +_BOOTSTRAP_CATEGORIES: list[tuple[str, str, str]] = [ + ("27386", "Graphics Cards", "Consumer Electronics > Computers > Components > Graphics/Video Cards"), + ("164", "CPUs/Processors", "Consumer Electronics > Computers > Components > CPUs/Processors"), + ("170083","RAM", "Consumer Electronics > Computers > Components > Memory (RAM)"), + ("175669","Solid State Drives", "Consumer Electronics > Computers > Components > Drives > Solid State Drives"), + ("177089","Hard Drives", "Consumer Electronics > Computers > Components > Drives > Hard Drives"), + ("179142","Laptops", "Consumer Electronics > Computers > Laptops & Netbooks"), + ("171957","Desktop Computers", "Consumer Electronics > Computers > Desktops & All-in-Ones"), + ("293", "Consumer Electronics","Consumer Electronics"), + ("625", "Cameras", "Consumer Electronics > Cameras & Photography > Digital Cameras"), + ("15052", "Vintage Cameras", "Consumer Electronics > Cameras & Photography > Vintage Movie Cameras"), + ("11724", "Audio Equipment", "Consumer Electronics > TV, Video & Home Audio > Home Audio"), + ("3676", "Vinyl Records", "Music > Records"), + ("870", "Musical Instruments","Musical Instruments & Gear"), + ("31388", "Video Game Consoles","Video Games & Consoles > Video Game Consoles"), + ("139971","Video Games", "Video Games & Consoles > Video Games"), + ("139973","Video Game Accessories", "Video Games & Consoles > Video Game Accessories"), + ("14308", "Networking Gear", "Computers/Tablets & Networking > Home Networking & Connectivity"), + ("182062","Smartphones", "Cell Phones & Smartphones"), + ("9394", "Tablets", "Computers/Tablets & Networking > Tablets & eBook Readers"), + ("11233", "Collectibles", "Collectibles"), +] + + +class EbayCategoryCache: + """Caches eBay leaf categories in SQLite for LLM prompt injection. + + Args: + conn: An open sqlite3.Connection with migration 011 already applied. + """ + + def __init__(self, conn: sqlite3.Connection) -> None: + self._conn = conn + + def is_stale(self, max_age_days: int = 7) -> bool: + """Return True if the cache is empty or all entries are older than max_age_days.""" + cur = self._conn.execute("SELECT MAX(refreshed_at) FROM ebay_categories") + row = cur.fetchone() + if not row or not row[0]: + return True + try: + latest = datetime.fromisoformat(row[0]) + if latest.tzinfo is None: + latest = latest.replace(tzinfo=timezone.utc) + return datetime.now(timezone.utc) - latest > timedelta(days=max_age_days) + except ValueError: + return True + + def _seed_bootstrap(self) -> None: + """Insert the hardcoded bootstrap categories. Idempotent (ON CONFLICT IGNORE).""" + now = datetime.now(timezone.utc).isoformat() + self._conn.executemany( + "INSERT OR IGNORE INTO ebay_categories" + " (category_id, name, full_path, is_leaf, refreshed_at)" + " VALUES (?, ?, ?, 1, ?)", + [(cid, name, path, now) for cid, name, path in _BOOTSTRAP_CATEGORIES], + ) + self._conn.commit() + log.info("EbayCategoryCache: seeded %d bootstrap categories.", len(_BOOTSTRAP_CATEGORIES)) diff --git a/tests/test_ebay_categories.py b/tests/test_ebay_categories.py new file mode 100644 index 0000000..de791e4 --- /dev/null +++ b/tests/test_ebay_categories.py @@ -0,0 +1,58 @@ +"""Unit tests for EbayCategoryCache.""" +from __future__ import annotations + +import sqlite3 +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from app.platforms.ebay.categories import EbayCategoryCache + +BOOTSTRAP_MIN = 10 # bootstrap must seed at least this many rows + + +@pytest.fixture +def db(tmp_path): + """In-memory SQLite with migrations applied.""" + from circuitforge_core.db import get_connection, run_migrations + conn = get_connection(tmp_path / "test.db") + run_migrations(conn, Path("app/db/migrations")) + return conn + + +def test_is_stale_empty_db(db): + cache = EbayCategoryCache(db) + assert cache.is_stale() is True + + +def test_is_stale_fresh(db): + now = datetime.now(timezone.utc).isoformat() + db.execute( + "INSERT INTO ebay_categories (category_id, name, full_path, is_leaf, refreshed_at)" + " VALUES (?, ?, ?, 1, ?)", + ("12345", "Graphics Cards", "Consumer Electronics > GPUs > Graphics Cards", now), + ) + db.commit() + cache = EbayCategoryCache(db) + assert cache.is_stale() is False + + +def test_is_stale_old(db): + old = (datetime.now(timezone.utc) - timedelta(days=8)).isoformat() + db.execute( + "INSERT INTO ebay_categories (category_id, name, full_path, is_leaf, refreshed_at)" + " VALUES (?, ?, ?, 1, ?)", + ("12345", "Graphics Cards", "Consumer Electronics > GPUs > Graphics Cards", old), + ) + db.commit() + cache = EbayCategoryCache(db) + assert cache.is_stale() is True + + +def test_seed_bootstrap_populates_rows(db): + cache = EbayCategoryCache(db) + cache._seed_bootstrap() + cur = db.execute("SELECT COUNT(*) FROM ebay_categories") + count = cur.fetchone()[0] + assert count >= BOOTSTRAP_MIN From 7c73186394b6840533da887900a54042b5ef6882 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 14 Apr 2026 11:06:13 -0700 Subject: [PATCH 03/13] feat: EbayCategoryCache get_relevant and get_all_for_prompt --- app/platforms/ebay/categories.py | 38 ++++++++++++++++++++++++++++++++ tests/test_ebay_categories.py | 31 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/app/platforms/ebay/categories.py b/app/platforms/ebay/categories.py index d02896d..82e8d97 100644 --- a/app/platforms/ebay/categories.py +++ b/app/platforms/ebay/categories.py @@ -76,3 +76,41 @@ class EbayCategoryCache: ) self._conn.commit() log.info("EbayCategoryCache: seeded %d bootstrap categories.", len(_BOOTSTRAP_CATEGORIES)) + + def get_relevant( + self, + keywords: list[str], + limit: int = 30, + ) -> list[tuple[str, str]]: + """Return (category_id, full_path) pairs matching any keyword. + + Matches against both name and full_path (case-insensitive LIKE). + Returns at most `limit` rows. + """ + if not keywords: + return [] + conditions = " OR ".join( + "LOWER(name) LIKE ? OR LOWER(full_path) LIKE ?" for _ in keywords + ) + params: list[str] = [] + for kw in keywords: + like = f"%{kw.lower()}%" + params.extend([like, like]) + params.append(limit) + cur = self._conn.execute( + f"SELECT category_id, full_path FROM ebay_categories" + f" WHERE {conditions} ORDER BY name LIMIT ?", + params, + ) + return [(row[0], row[1]) for row in cur.fetchall()] + + def get_all_for_prompt(self, limit: int = 80) -> list[tuple[str, str]]: + """Return up to `limit` (category_id, full_path) pairs, sorted by name. + + Used when no keyword context is available. + """ + cur = self._conn.execute( + "SELECT category_id, full_path FROM ebay_categories ORDER BY name LIMIT ?", + (limit,), + ) + return [(row[0], row[1]) for row in cur.fetchall()] diff --git a/tests/test_ebay_categories.py b/tests/test_ebay_categories.py index de791e4..531e70a 100644 --- a/tests/test_ebay_categories.py +++ b/tests/test_ebay_categories.py @@ -56,3 +56,34 @@ def test_seed_bootstrap_populates_rows(db): cur = db.execute("SELECT COUNT(*) FROM ebay_categories") count = cur.fetchone()[0] assert count >= BOOTSTRAP_MIN + + +def test_get_relevant_keyword_match(db): + cache = EbayCategoryCache(db) + cache._seed_bootstrap() + results = cache.get_relevant(["GPU", "graphics"], limit=5) + ids = [r[0] for r in results] + assert "27386" in ids # Graphics Cards + + +def test_get_relevant_no_match(db): + cache = EbayCategoryCache(db) + cache._seed_bootstrap() + results = cache.get_relevant(["zzznomatch_xyzxyz"], limit=5) + assert results == [] + + +def test_get_relevant_respects_limit(db): + cache = EbayCategoryCache(db) + cache._seed_bootstrap() + results = cache.get_relevant(["electronics"], limit=3) + assert len(results) <= 3 + + +def test_get_all_for_prompt_returns_rows(db): + cache = EbayCategoryCache(db) + cache._seed_bootstrap() + results = cache.get_all_for_prompt(limit=10) + assert len(results) > 0 + # Each entry is (category_id, full_path) + assert all(len(r) == 2 for r in results) From 0b8cb639682540bad68e48b2fc205d4db6215c7a Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 14 Apr 2026 11:14:52 -0700 Subject: [PATCH 04/13] feat: EbayCategoryCache refresh from eBay Taxonomy API with bootstrap fallback --- app/platforms/ebay/categories.py | 94 ++++++++++++++++++++++++++++++++ tests/test_ebay_categories.py | 75 +++++++++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/app/platforms/ebay/categories.py b/app/platforms/ebay/categories.py index 82e8d97..d94e747 100644 --- a/app/platforms/ebay/categories.py +++ b/app/platforms/ebay/categories.py @@ -13,6 +13,8 @@ import sqlite3 from datetime import datetime, timedelta, timezone from typing import Optional +import requests + log = logging.getLogger(__name__) # Bootstrap table — common categories for self-hosters without eBay API credentials. @@ -114,3 +116,95 @@ class EbayCategoryCache: (limit,), ) return [(row[0], row[1]) for row in cur.fetchall()] + + def refresh( + self, + token_manager: Optional["EbayTokenManager"] = None, + ) -> int: + """Fetch the eBay category tree and upsert leaf nodes into SQLite. + + Args: + token_manager: An `EbayTokenManager` instance for the Taxonomy API. + If None, falls back to seeding the hardcoded bootstrap table. + + Returns: + Number of leaf categories stored. + """ + if token_manager is None: + self._seed_bootstrap() + cur = self._conn.execute("SELECT COUNT(*) FROM ebay_categories") + return cur.fetchone()[0] + + try: + token = token_manager.get_token() + headers = {"Authorization": f"Bearer {token}"} + + # Step 1: get default tree ID for EBAY_US + id_resp = requests.get( + "https://api.ebay.com/commerce/taxonomy/v1/get_default_category_tree_id", + params={"marketplace_id": "EBAY_US"}, + headers=headers, + timeout=30, + ) + id_resp.raise_for_status() + tree_id = id_resp.json()["categoryTreeId"] + + # Step 2: fetch full tree (large response — may take several seconds) + tree_resp = requests.get( + f"https://api.ebay.com/commerce/taxonomy/v1/category_tree/{tree_id}", + headers=headers, + timeout=120, + ) + tree_resp.raise_for_status() + tree = tree_resp.json() + + leaves: list[tuple[str, str, str]] = [] + _extract_leaves(tree["rootCategoryNode"], path="", leaves=leaves) + + now = datetime.now(timezone.utc).isoformat() + self._conn.executemany( + "INSERT OR REPLACE INTO ebay_categories" + " (category_id, name, full_path, is_leaf, refreshed_at)" + " VALUES (?, ?, ?, 1, ?)", + [(cid, name, path, now) for cid, name, path in leaves], + ) + self._conn.commit() + log.info( + "EbayCategoryCache: refreshed %d leaf categories from eBay Taxonomy API.", + len(leaves), + ) + return len(leaves) + + except Exception: + log.warning( + "EbayCategoryCache: Taxonomy API refresh failed — falling back to bootstrap.", + exc_info=True, + ) + self._seed_bootstrap() + cur = self._conn.execute("SELECT COUNT(*) FROM ebay_categories") + return cur.fetchone()[0] + + +def _extract_leaves( + node: dict, + path: str, + leaves: list[tuple[str, str, str]], +) -> None: + """Recursively walk the eBay category tree, collecting leaf node tuples. + + Args: + node: A categoryTreeNode dict from the eBay Taxonomy API response. + path: The ancestor breadcrumb, e.g. "Consumer Electronics > Computers". + leaves: Accumulator list of (category_id, name, full_path) tuples. + """ + cat = node["category"] + cat_id: str = cat["categoryId"] + cat_name: str = cat["categoryName"] + full_path = f"{path} > {cat_name}" if path else cat_name + + if node.get("leafCategoryTreeNode", False): + leaves.append((cat_id, cat_name, full_path)) + return # leaf — no children to recurse into + + for child in node.get("childCategoryTreeNodes", []): + _extract_leaves(child, full_path, leaves) diff --git a/tests/test_ebay_categories.py b/tests/test_ebay_categories.py index 531e70a..eb899cc 100644 --- a/tests/test_ebay_categories.py +++ b/tests/test_ebay_categories.py @@ -4,6 +4,7 @@ from __future__ import annotations import sqlite3 from datetime import datetime, timedelta, timezone from pathlib import Path +from unittest.mock import MagicMock, patch import pytest @@ -87,3 +88,77 @@ def test_get_all_for_prompt_returns_rows(db): assert len(results) > 0 # Each entry is (category_id, full_path) assert all(len(r) == 2 for r in results) + + +def _make_tree_response() -> dict: + """Minimal eBay Taxonomy API tree response with two leaf nodes.""" + return { + "categoryTreeId": "0", + "rootCategoryNode": { + "category": {"categoryId": "6000", "categoryName": "Root"}, + "leafCategoryTreeNode": False, + "childCategoryTreeNodes": [ + { + "category": {"categoryId": "6001", "categoryName": "Electronics"}, + "leafCategoryTreeNode": False, + "childCategoryTreeNodes": [ + { + "category": {"categoryId": "6002", "categoryName": "GPUs"}, + "leafCategoryTreeNode": True, + "childCategoryTreeNodes": [], + }, + { + "category": {"categoryId": "6003", "categoryName": "CPUs"}, + "leafCategoryTreeNode": True, + "childCategoryTreeNodes": [], + }, + ], + } + ], + }, + } + + +def test_refresh_inserts_leaf_nodes(db): + mock_tm = MagicMock() + mock_tm.get_token.return_value = "fake-token" + + tree_resp = MagicMock() + tree_resp.raise_for_status = MagicMock() + tree_resp.json.return_value = _make_tree_response() + + id_resp = MagicMock() + id_resp.raise_for_status = MagicMock() + id_resp.json.return_value = {"categoryTreeId": "0"} + + with patch("app.platforms.ebay.categories.requests.get") as mock_get: + mock_get.side_effect = [id_resp, tree_resp] + cache = EbayCategoryCache(db) + count = cache.refresh(mock_tm) + + assert count == 2 # two leaf nodes in our fake tree + cur = db.execute("SELECT category_id FROM ebay_categories ORDER BY category_id") + ids = {row[0] for row in cur.fetchall()} + assert "6002" in ids + assert "6003" in ids + + +def test_refresh_no_token_manager_seeds_bootstrap(db): + cache = EbayCategoryCache(db) + count = cache.refresh(token_manager=None) + assert count >= BOOTSTRAP_MIN + + +def test_refresh_api_error_logs_warning(db, caplog): + import logging + mock_tm = MagicMock() + mock_tm.get_token.return_value = "fake-token" + + with patch("app.platforms.ebay.categories.requests.get") as mock_get: + mock_get.side_effect = Exception("network error") + cache = EbayCategoryCache(db) + with caplog.at_level(logging.WARNING, logger="app.platforms.ebay.categories"): + count = cache.refresh(mock_tm) + + # Falls back to bootstrap on API error + assert count >= BOOTSTRAP_MIN From 15718ab431b8957dc0a052925042bff740e2acb2 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 14 Apr 2026 11:38:12 -0700 Subject: [PATCH 05/13] feat: community category federation in EbayCategoryCache.refresh() Adds optional community_store param to refresh(). Credentialed instances publish leaf categories to the shared community PostgreSQL after a successful Taxonomy API fetch. Credentialless instances pull from community (requires >= 10 rows) before falling back to the hardcoded bootstrap. Adds 3 new tests (14 total, all passing). --- app/platforms/ebay/categories.py | 44 ++++++++++++++++++++++++++ tests/test_ebay_categories.py | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/app/platforms/ebay/categories.py b/app/platforms/ebay/categories.py index d94e747..94c8eab 100644 --- a/app/platforms/ebay/categories.py +++ b/app/platforms/ebay/categories.py @@ -120,17 +120,50 @@ class EbayCategoryCache: def refresh( self, token_manager: Optional["EbayTokenManager"] = None, + community_store: Optional[object] = None, ) -> int: """Fetch the eBay category tree and upsert leaf nodes into SQLite. Args: token_manager: An `EbayTokenManager` instance for the Taxonomy API. If None, falls back to seeding the hardcoded bootstrap table. + community_store: Optional SnipeCommunityStore instance. + If provided and token_manager is set, publish leaves after a successful + Taxonomy API fetch. + If provided and token_manager is None, fetch from community before + falling back to the hardcoded bootstrap (requires >= 10 rows). Returns: Number of leaf categories stored. """ if token_manager is None: + # Try community store first + if community_store is not None: + try: + community_cats = community_store.fetch_categories() + if len(community_cats) >= 10: + now = datetime.now(timezone.utc).isoformat() + self._conn.executemany( + "INSERT OR REPLACE INTO ebay_categories" + " (category_id, name, full_path, is_leaf, refreshed_at)" + " VALUES (?, ?, ?, 1, ?)", + [(cid, name, path, now) for cid, name, path in community_cats], + ) + self._conn.commit() + log.info( + "EbayCategoryCache: loaded %d categories from community store.", + len(community_cats), + ) + return len(community_cats) + log.info( + "EbayCategoryCache: community store has %d categories (< 10) — falling back to bootstrap.", + len(community_cats), + ) + except Exception: + log.warning( + "EbayCategoryCache: community store fetch failed — falling back to bootstrap.", + exc_info=True, + ) self._seed_bootstrap() cur = self._conn.execute("SELECT COUNT(*) FROM ebay_categories") return cur.fetchone()[0] @@ -173,6 +206,17 @@ class EbayCategoryCache: "EbayCategoryCache: refreshed %d leaf categories from eBay Taxonomy API.", len(leaves), ) + + # Publish to community store if available + if community_store is not None: + try: + community_store.publish_categories(leaves) + except Exception: + log.warning( + "EbayCategoryCache: failed to publish categories to community store.", + exc_info=True, + ) + return len(leaves) except Exception: diff --git a/tests/test_ebay_categories.py b/tests/test_ebay_categories.py index eb899cc..b04a5ee 100644 --- a/tests/test_ebay_categories.py +++ b/tests/test_ebay_categories.py @@ -162,3 +162,57 @@ def test_refresh_api_error_logs_warning(db, caplog): # Falls back to bootstrap on API error assert count >= BOOTSTRAP_MIN + + +def test_refresh_publishes_to_community_when_creds_available(db): + """After a successful Taxonomy API refresh, categories are published to community store.""" + mock_tm = MagicMock() + mock_tm.get_token.return_value = "fake-token" + + id_resp = MagicMock() + id_resp.raise_for_status = MagicMock() + id_resp.json.return_value = {"categoryTreeId": "0"} + + tree_resp = MagicMock() + tree_resp.raise_for_status = MagicMock() + tree_resp.json.return_value = _make_tree_response() + + mock_community = MagicMock() + mock_community.publish_categories.return_value = 2 + + with patch("app.platforms.ebay.categories.requests.get") as mock_get: + mock_get.side_effect = [id_resp, tree_resp] + cache = EbayCategoryCache(db) + cache.refresh(mock_tm, community_store=mock_community) + + mock_community.publish_categories.assert_called_once() + published = mock_community.publish_categories.call_args[0][0] + assert len(published) == 2 + + +def test_refresh_fetches_from_community_when_no_creds(db): + """Without creds, community categories are used when available (>= 10 rows).""" + mock_community = MagicMock() + mock_community.fetch_categories.return_value = [ + (str(i), f"Cat {i}", f"Path > Cat {i}") for i in range(15) + ] + + cache = EbayCategoryCache(db) + count = cache.refresh(token_manager=None, community_store=mock_community) + + assert count == 15 + cur = db.execute("SELECT COUNT(*) FROM ebay_categories") + assert cur.fetchone()[0] == 15 + + +def test_refresh_falls_back_to_bootstrap_when_community_sparse(db): + """Falls back to bootstrap if community returns fewer than 10 rows.""" + mock_community = MagicMock() + mock_community.fetch_categories.return_value = [ + ("1", "Only One", "Path > Only One") + ] + + cache = EbayCategoryCache(db) + count = cache.refresh(token_manager=None, community_store=mock_community) + + assert count >= BOOTSTRAP_MIN From 3c54a65dda4b8ec00dccd7ed24bfb13bf79e0190 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 14 Apr 2026 11:40:44 -0700 Subject: [PATCH 06/13] feat: SearchParamsResponse dataclass and JSON parser for LLM query builder --- app/llm/__init__.py | 5 ++ app/llm/query_translator.py | 90 ++++++++++++++++++++++++++++++++++ tests/test_query_translator.py | 72 +++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 app/llm/__init__.py create mode 100644 app/llm/query_translator.py create mode 100644 tests/test_query_translator.py diff --git a/app/llm/__init__.py b/app/llm/__init__.py new file mode 100644 index 0000000..b18a97e --- /dev/null +++ b/app/llm/__init__.py @@ -0,0 +1,5 @@ +# app/llm/__init__.py +# BSL 1.1 License +from .query_translator import QueryTranslator, QueryTranslatorError, SearchParamsResponse + +__all__ = ["QueryTranslator", "QueryTranslatorError", "SearchParamsResponse"] diff --git a/app/llm/query_translator.py b/app/llm/query_translator.py new file mode 100644 index 0000000..5ee8a83 --- /dev/null +++ b/app/llm/query_translator.py @@ -0,0 +1,90 @@ +# app/llm/query_translator.py +# BSL 1.1 License +"""LLM query builder — translates natural language to eBay SearchFilters. + +The QueryTranslator calls LLMRouter.complete() (synchronous) with a domain-aware +system prompt. The prompt includes category hints injected from EbayCategoryCache. +The LLM returns a single JSON object matching SearchParamsResponse. +""" +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import Optional + +log = logging.getLogger(__name__) + + +class QueryTranslatorError(Exception): + """Raised when the LLM output cannot be parsed into SearchParamsResponse.""" + def __init__(self, message: str, raw: str = "") -> None: + super().__init__(message) + self.raw = raw + + +@dataclass(frozen=True) +class SearchParamsResponse: + """Parsed LLM response — maps 1:1 to the /api/search query parameters.""" + base_query: str + must_include_mode: str # "all" | "any" | "groups" + must_include: str # raw filter string + must_exclude: str # comma-separated exclusion terms + max_price: Optional[float] + min_price: Optional[float] + condition: list[str] # subset of ["new", "used", "for_parts"] + category_id: Optional[str] # eBay category ID string, or None + explanation: str # one-sentence plain-language summary + + +_VALID_MODES = {"all", "any", "groups"} +_VALID_CONDITIONS = {"new", "used", "for_parts"} + + +def _parse_response(raw: str) -> SearchParamsResponse: + """Parse the LLM's raw text output into a SearchParamsResponse. + + Raises QueryTranslatorError if the JSON is malformed or required fields + are missing. + """ + try: + data = json.loads(raw.strip()) + except json.JSONDecodeError as exc: + raise QueryTranslatorError(f"LLM returned unparseable JSON: {exc}", raw=raw) from exc + + try: + base_query = str(data["base_query"]).strip() + if not base_query: + raise KeyError("base_query is empty") + must_include_mode = str(data.get("must_include_mode", "all")) + if must_include_mode not in _VALID_MODES: + must_include_mode = "all" + must_include = str(data.get("must_include", "")) + must_exclude = str(data.get("must_exclude", "")) + max_price = float(data["max_price"]) if data.get("max_price") is not None else None + min_price = float(data["min_price"]) if data.get("min_price") is not None else None + raw_conditions = data.get("condition", []) + condition = [c for c in raw_conditions if c in _VALID_CONDITIONS] + category_id = str(data["category_id"]) if data.get("category_id") else None + explanation = str(data.get("explanation", "")).strip() + except (KeyError, TypeError, ValueError) as exc: + raise QueryTranslatorError( + f"LLM response missing or invalid field: {exc}", raw=raw + ) from exc + + return SearchParamsResponse( + base_query=base_query, + must_include_mode=must_include_mode, + must_include=must_include, + must_exclude=must_exclude, + max_price=max_price, + min_price=min_price, + condition=condition, + category_id=category_id, + explanation=explanation, + ) + + +class QueryTranslator: + """Stub — implemented in Task 6.""" + pass diff --git a/tests/test_query_translator.py b/tests/test_query_translator.py new file mode 100644 index 0000000..53380c4 --- /dev/null +++ b/tests/test_query_translator.py @@ -0,0 +1,72 @@ +"""Unit tests for QueryTranslator — LLMRouter mocked at boundary.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from app.llm.query_translator import QueryTranslatorError, SearchParamsResponse, _parse_response + + +# ── _parse_response ─────────────────────────────────────────────────────────── + +def test_parse_response_happy_path(): + raw = json.dumps({ + "base_query": "RTX 3080", + "must_include_mode": "groups", + "must_include": "rtx|geforce, 3080", + "must_exclude": "mining,for parts", + "max_price": 300.0, + "min_price": None, + "condition": ["used"], + "category_id": "27386", + "explanation": "Searching for used RTX 3080 GPUs under $300.", + }) + result = _parse_response(raw) + assert result.base_query == "RTX 3080" + assert result.must_include_mode == "groups" + assert result.max_price == 300.0 + assert result.min_price is None + assert result.condition == ["used"] + assert result.category_id == "27386" + assert "RTX 3080" in result.explanation + + +def test_parse_response_missing_optional_fields(): + raw = json.dumps({ + "base_query": "vintage camera", + "must_include_mode": "all", + "must_include": "", + "must_exclude": "", + "max_price": None, + "min_price": None, + "condition": [], + "category_id": None, + "explanation": "Searching for vintage cameras.", + }) + result = _parse_response(raw) + assert result.category_id is None + assert result.max_price is None + assert result.condition == [] + + +def test_parse_response_invalid_json(): + with pytest.raises(QueryTranslatorError, match="unparseable"): + _parse_response("this is not json {{{ garbage") + + +def test_parse_response_missing_required_field(): + # base_query is required — missing it should raise + raw = json.dumps({ + "must_include_mode": "all", + "must_include": "", + "must_exclude": "", + "max_price": None, + "min_price": None, + "condition": [], + "category_id": None, + "explanation": "oops", + }) + with pytest.raises(QueryTranslatorError): + _parse_response(raw) From 7720f1def5c91dbccc05423ea40a1d670eda76fb Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 14 Apr 2026 11:43:19 -0700 Subject: [PATCH 07/13] feat: QueryTranslator with domain-aware system prompt and category hint injection --- app/llm/query_translator.py | 83 ++++++++++++++++++++++++++- tests/test_query_translator.py | 100 ++++++++++++++++++++++++++++++++- 2 files changed, 179 insertions(+), 4 deletions(-) diff --git a/app/llm/query_translator.py b/app/llm/query_translator.py index 5ee8a83..6b18c47 100644 --- a/app/llm/query_translator.py +++ b/app/llm/query_translator.py @@ -11,7 +11,10 @@ from __future__ import annotations import json import logging from dataclasses import dataclass -from typing import Optional +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from app.platforms.ebay.categories import EbayCategoryCache log = logging.getLogger(__name__) @@ -85,6 +88,80 @@ def _parse_response(raw: str) -> SearchParamsResponse: ) +# ── System prompt template ──────────────────────────────────────────────────── + +_SYSTEM_PROMPT_TEMPLATE = """\ +You are a search assistant for Snipe, an eBay listing intelligence tool. +Your job is to translate a natural-language description of what someone is looking for +into a structured eBay search configuration. + +Return ONLY a JSON object with these exact fields — no preamble, no markdown, no extra keys: + base_query (string) Primary search term, short — e.g. "RTX 3080", "vintage Leica" + must_include_mode (string) One of: "all" (AND), "any" (OR), "groups" (CNF: pipe=OR within group, comma=AND between groups) + must_include (string) Filter string per mode — leave blank if nothing to filter + must_exclude (string) Comma-separated terms to exclude — e.g. "mining,for parts,broken" + max_price (number|null) Maximum price in USD, or null + min_price (number|null) Minimum price in USD, or null + condition (array) Any of: "new", "used", "for_parts" — empty array means any condition + category_id (string|null) eBay category ID from the list below, or null if no match + explanation (string) One plain sentence summarizing what you built + +eBay "groups" mode syntax example: to find a GPU that is BOTH (nvidia OR amd) AND (16gb OR 8gb): + must_include_mode: "groups" + must_include: "nvidia|amd, 16gb|8gb" + +Phrase "like new", "open box", "refurbished" -> condition: ["used"] +Phrase "broken", "for parts", "not working" -> condition: ["for_parts"] +If unsure about condition, use an empty array. + +Available eBay categories (use category_id verbatim if one fits — otherwise omit): +{category_hints} + +If none match, omit category_id (set to null). Respond with valid JSON only. No commentary outside the JSON object. +""" + + +# ── QueryTranslator ─────────────────────────────────────────────────────────── + class QueryTranslator: - """Stub — implemented in Task 6.""" - pass + """Translates natural-language search descriptions into SearchParamsResponse. + + Args: + category_cache: An EbayCategoryCache instance (may have empty cache). + llm_router: An LLMRouter instance from circuitforge_core. + """ + + def __init__(self, category_cache: "EbayCategoryCache", llm_router: object) -> None: + self._cache = category_cache + self._llm_router = llm_router + + def translate(self, natural_language: str) -> SearchParamsResponse: + """Translate a natural-language query into a SearchParamsResponse. + + Raises QueryTranslatorError if the LLM fails or returns bad JSON. + """ + # Extract up to 10 keywords for category hint lookup + keywords = [w for w in natural_language.split()[:10] if len(w) > 2] + hints = self._cache.get_relevant(keywords, limit=30) + if not hints: + hints = self._cache.get_all_for_prompt(limit=40) + + if hints: + category_hints = "\n".join(f"{cid}: {path}" for cid, path in hints) + else: + category_hints = "(no categories cached — omit category_id)" + + system_prompt = _SYSTEM_PROMPT_TEMPLATE.format(category_hints=category_hints) + + try: + raw = self._llm_router.complete( + natural_language, + system=system_prompt, + max_tokens=512, + ) + except Exception as exc: + raise QueryTranslatorError( + f"LLM backend error: {exc}", raw="" + ) from exc + + return _parse_response(raw) diff --git a/tests/test_query_translator.py b/tests/test_query_translator.py index 53380c4..20f16eb 100644 --- a/tests/test_query_translator.py +++ b/tests/test_query_translator.py @@ -2,11 +2,12 @@ from __future__ import annotations import json +from pathlib import Path from unittest.mock import MagicMock, patch import pytest -from app.llm.query_translator import QueryTranslatorError, SearchParamsResponse, _parse_response +from app.llm.query_translator import QueryTranslator, QueryTranslatorError, SearchParamsResponse, _parse_response # ── _parse_response ─────────────────────────────────────────────────────────── @@ -70,3 +71,100 @@ def test_parse_response_missing_required_field(): }) with pytest.raises(QueryTranslatorError): _parse_response(raw) + + +# ── QueryTranslator (integration with mocked LLMRouter) ────────────────────── + +from app.platforms.ebay.categories import EbayCategoryCache +from circuitforge_core.db import get_connection, run_migrations + + +@pytest.fixture +def db_with_categories(tmp_path): + conn = get_connection(tmp_path / "test.db") + run_migrations(conn, Path("app/db/migrations")) + cache = EbayCategoryCache(conn) + cache._seed_bootstrap() + return conn + + +def _make_translator(db_conn, llm_response: str) -> QueryTranslator: + from app.platforms.ebay.categories import EbayCategoryCache + cache = EbayCategoryCache(db_conn) + mock_router = MagicMock() + mock_router.complete.return_value = llm_response + return QueryTranslator(category_cache=cache, llm_router=mock_router) + + +def test_translate_returns_search_params(db_with_categories): + llm_out = json.dumps({ + "base_query": "RTX 3080", + "must_include_mode": "groups", + "must_include": "rtx|geforce, 3080", + "must_exclude": "mining,for parts", + "max_price": 300.0, + "min_price": None, + "condition": ["used"], + "category_id": "27386", + "explanation": "Searching for used RTX 3080 GPUs under $300.", + }) + t = _make_translator(db_with_categories, llm_out) + result = t.translate("used RTX 3080 under $300 no mining") + assert result.base_query == "RTX 3080" + assert result.max_price == 300.0 + + +def test_translate_injects_category_hints(db_with_categories): + """The system prompt sent to the LLM must contain category_id hints.""" + llm_out = json.dumps({ + "base_query": "GPU", + "must_include_mode": "all", + "must_include": "", + "must_exclude": "", + "max_price": None, + "min_price": None, + "condition": [], + "category_id": None, + "explanation": "Searching for GPUs.", + }) + t = _make_translator(db_with_categories, llm_out) + t.translate("GPU") + call_args = t._llm_router.complete.call_args + system_prompt = call_args.kwargs.get("system") or call_args.args[1] + # Bootstrap seeds "27386" for Graphics Cards — should appear in the prompt + assert "27386" in system_prompt + + +def test_translate_empty_category_cache_still_works(tmp_path): + """No crash when category cache is empty — prompt uses fallback text.""" + from circuitforge_core.db import get_connection, run_migrations + conn = get_connection(tmp_path / "empty.db") + run_migrations(conn, Path("app/db/migrations")) + # Do NOT seed bootstrap — empty cache + llm_out = json.dumps({ + "base_query": "vinyl", + "must_include_mode": "all", + "must_include": "", + "must_exclude": "", + "max_price": None, + "min_price": None, + "condition": [], + "category_id": None, + "explanation": "Searching for vinyl records.", + }) + t = _make_translator(conn, llm_out) + result = t.translate("vinyl records") + assert result.base_query == "vinyl" + call_args = t._llm_router.complete.call_args + system_prompt = call_args.kwargs.get("system") or call_args.args[1] + assert "If none match" in system_prompt + + +def test_translate_llm_error_raises_query_translator_error(db_with_categories): + from app.platforms.ebay.categories import EbayCategoryCache + cache = EbayCategoryCache(db_with_categories) + mock_router = MagicMock() + mock_router.complete.side_effect = RuntimeError("all backends exhausted") + t = QueryTranslator(category_cache=cache, llm_router=mock_router) + with pytest.raises(QueryTranslatorError, match="LLM backend"): + t.translate("used GPU") From 93f989c82115ea69940cfdfd1702185488bf2c46 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 14 Apr 2026 11:44:53 -0700 Subject: [PATCH 08/13] feat: add llm_query_builder tier gate (paid+) to tiers.py and SessionFeatures --- api/cloud_session.py | 2 ++ app/tiers.py | 1 + tests/test_tiers.py | 10 ++++++++++ 3 files changed, 13 insertions(+) diff --git a/api/cloud_session.py b/api/cloud_session.py index ab97abc..99d9de6 100644 --- a/api/cloud_session.py +++ b/api/cloud_session.py @@ -69,6 +69,7 @@ class SessionFeatures: photo_analysis: bool shared_scammer_db: bool shared_image_db: bool + llm_query_builder: bool def compute_features(tier: str) -> SessionFeatures: @@ -85,6 +86,7 @@ def compute_features(tier: str) -> SessionFeatures: photo_analysis=paid_plus, shared_scammer_db=paid_plus, shared_image_db=paid_plus, + llm_query_builder=paid_plus, ) diff --git a/app/tiers.py b/app/tiers.py index d41eafd..29b16b3 100644 --- a/app/tiers.py +++ b/app/tiers.py @@ -26,6 +26,7 @@ FEATURES: dict[str, str] = { "reverse_image_search": "paid", "ebay_oauth": "paid", # full trust scores via eBay Trading API "background_monitoring": "paid", # limited at Paid; see LIMITS below + "llm_query_builder": "paid", # inline natural-language → filter translator # Premium tier "auto_bidding": "premium", diff --git a/tests/test_tiers.py b/tests/test_tiers.py index 18c0162..455968e 100644 --- a/tests/test_tiers.py +++ b/tests/test_tiers.py @@ -22,3 +22,13 @@ def test_saved_searches_are_free(): # Ungated: retention feature — friction cost outweighs gate value (see tiers.py) assert can_use("saved_searches", tier="free") is True assert can_use("saved_searches", tier="paid") is True + + +def test_llm_query_builder_is_paid(): + assert can_use("llm_query_builder", tier="free") is False + assert can_use("llm_query_builder", tier="paid") is True + + +def test_llm_query_builder_local_vision_does_not_unlock(): + # local vision unlocks photo features only, not LLM query builder + assert can_use("llm_query_builder", tier="free", has_local_vision=True) is False From cdc4e40775e5a98ce8bdcdf3ca2b094e40460554 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 14 Apr 2026 11:46:15 -0700 Subject: [PATCH 09/13] feat: POST /api/search/build endpoint with tier gate and category cache wiring --- api/main.py | 96 ++++++++++++++++++++++++++++++++++ tests/test_api_search_build.py | 83 +++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 tests/test_api_search_build.py diff --git a/api/main.py b/api/main.py index 43064d2..83333c5 100644 --- a/api/main.py +++ b/api/main.py @@ -57,6 +57,15 @@ def _get_community_store() -> "SnipeCommunityStore | None": return _community_store +# ── LLM Query Builder singletons (optional — requires LLM backend) ──────────── +_category_cache = None +_query_translator = None + + +def _get_query_translator(): + return _query_translator + + @asynccontextmanager async def _lifespan(app: FastAPI): global _community_store @@ -84,6 +93,34 @@ async def _lifespan(app: FastAPI): else: log.debug("COMMUNITY_DB_URL not set — community trust signals disabled.") + # LLM Query Builder — category cache + translator (best-effort, never blocks startup) + global _category_cache, _query_translator + try: + from app.platforms.ebay.categories import EbayCategoryCache + from app.llm.query_translator import QueryTranslator + from circuitforge_core.db import get_connection, run_migrations as _run_migrations + from pathlib import Path as _Path + + _cat_conn = get_connection(sched_db) # use the same DB as the app + _run_migrations(_cat_conn, _Path("app/db/migrations")) + _category_cache = EbayCategoryCache(_cat_conn) + + if _category_cache.is_stale(): + _category_cache.refresh(token_manager=None) # bootstrap fallback + + try: + from circuitforge_core.llm import LLMRouter + _llm_router = LLMRouter() + _query_translator = QueryTranslator( + category_cache=_category_cache, + llm_router=_llm_router, + ) + log.info("LLM query builder ready.") + except Exception: + log.info("No LLM backend configured — query builder disabled.") + except Exception: + log.warning("LLM query builder init failed.", exc_info=True) + yield get_scheduler(sched_db).shutdown(timeout=10.0) @@ -968,3 +1005,62 @@ def patch_preference( return store.get_all_preferences() +# ── LLM Query Builder ───────────────────────────────────────────────────────── + +class BuildQueryRequest(BaseModel): + natural_language: str + + +@app.post("/api/search/build") +async def build_search_query( + body: BuildQueryRequest, + session: CloudUser = Depends(get_session), +) -> dict: + """Translate a natural-language description into eBay search parameters. + + Requires Paid tier or local mode. Returns a SearchParamsResponse JSON object + ready to pre-fill the search form. + """ + features = compute_features(session.tier) + if not features.llm_query_builder: + raise HTTPException( + status_code=402, + detail="LLM query builder requires Paid tier or above.", + ) + + translator = _get_query_translator() + if translator is None: + raise HTTPException( + status_code=503, + detail="No LLM backend configured. Set OLLAMA_HOST, ANTHROPIC_API_KEY, or OPENAI_API_KEY.", + ) + + from app.llm.query_translator import QueryTranslatorError + import asyncio + + loop = asyncio.get_event_loop() + try: + result = await loop.run_in_executor( + None, translator.translate, body.natural_language.strip() + ) + except QueryTranslatorError as exc: + raise HTTPException( + status_code=422, + detail={"message": str(exc), "raw": exc.raw}, + ) + except Exception as exc: + raise HTTPException(status_code=503, detail=f"LLM error: {exc}") + + return { + "base_query": result.base_query, + "must_include_mode": result.must_include_mode, + "must_include": result.must_include, + "must_exclude": result.must_exclude, + "max_price": result.max_price, + "min_price": result.min_price, + "condition": result.condition, + "category_id": result.category_id, + "explanation": result.explanation, + } + + diff --git a/tests/test_api_search_build.py b/tests/test_api_search_build.py new file mode 100644 index 0000000..e829857 --- /dev/null +++ b/tests/test_api_search_build.py @@ -0,0 +1,83 @@ +"""Integration tests for POST /api/search/build.""" +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(tmp_path): + """TestClient with a fresh DB and mocked LLMRouter/category cache.""" + import os + os.environ["SNIPE_DB"] = str(tmp_path / "snipe.db") + # Import app AFTER setting SNIPE_DB so the DB path is picked up + from api.main import app + return TestClient(app, raise_server_exceptions=False) + + +def _good_llm_response() -> str: + return json.dumps({ + "base_query": "RTX 3080", + "must_include_mode": "groups", + "must_include": "rtx|geforce, 3080", + "must_exclude": "mining", + "max_price": 300.0, + "min_price": None, + "condition": ["used"], + "category_id": "27386", + "explanation": "Used RTX 3080 under $300.", + }) + + +def test_build_endpoint_success(client): + with patch("api.main._get_query_translator") as mock_get_t: + mock_t = MagicMock() + from app.llm.query_translator import SearchParamsResponse + mock_t.translate.return_value = SearchParamsResponse( + base_query="RTX 3080", + must_include_mode="groups", + must_include="rtx|geforce, 3080", + must_exclude="mining", + max_price=300.0, + min_price=None, + condition=["used"], + category_id="27386", + explanation="Used RTX 3080 under $300.", + ) + mock_get_t.return_value = mock_t + resp = client.post( + "/api/search/build", + json={"natural_language": "used RTX 3080 under $300 no mining"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["base_query"] == "RTX 3080" + assert data["explanation"] == "Used RTX 3080 under $300." + + +def test_build_endpoint_llm_unavailable(client): + with patch("api.main._get_query_translator") as mock_get_t: + mock_get_t.return_value = None # no translator configured + resp = client.post( + "/api/search/build", + json={"natural_language": "GPU"}, + ) + assert resp.status_code == 503 + + +def test_build_endpoint_bad_json(client): + with patch("api.main._get_query_translator") as mock_get_t: + from app.llm.query_translator import QueryTranslatorError + mock_t = MagicMock() + mock_t.translate.side_effect = QueryTranslatorError("unparseable", raw="garbage output") + mock_get_t.return_value = mock_t + resp = client.post( + "/api/search/build", + json={"natural_language": "GPU"}, + ) + assert resp.status_code == 422 + assert "raw" in resp.json()["detail"] From 65ddd2f4fafe3c8b6db58068d55c0f7bd43af6d5 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 14 Apr 2026 11:49:22 -0700 Subject: [PATCH 10/13] feat: add llm_query_builder to SessionFeatures and populateFromLLM to search store --- web/src/stores/search.ts | 30 ++++++++++++++++++++++++++++++ web/src/stores/session.ts | 2 ++ 2 files changed, 32 insertions(+) diff --git a/web/src/stores/search.ts b/web/src/stores/search.ts index 3fca4f8..042e4c0 100644 --- a/web/src/stores/search.ts +++ b/web/src/stores/search.ts @@ -61,6 +61,18 @@ export interface SavedSearch { last_run_at: string | null } +export interface SearchParamsResult { + base_query: string + must_include_mode: string + must_include: string + must_exclude: string + max_price: number | null + min_price: number | null + condition: string[] + category_id: string | null + explanation: string +} + export interface SearchFilters { minTrustScore?: number minPrice?: number @@ -120,6 +132,7 @@ export const useSearchStore = defineStore('search', () => { ) const marketPrice = ref(cached?.marketPrice ?? null) const adapterUsed = ref<'api' | 'scraper' | null>(cached?.adapterUsed ?? null) + const filters = ref({}) const affiliateActive = ref(false) const loading = ref(false) const error = ref(null) @@ -281,6 +294,21 @@ export const useSearchStore = defineStore('search', () => { error.value = null } + function populateFromLLM(params: SearchParamsResult) { + query.value = params.base_query + const mode = params.must_include_mode as MustIncludeMode + filters.value = { + ...filters.value, + mustInclude: params.must_include, + mustIncludeMode: mode, + mustExclude: params.must_exclude, + maxPrice: params.max_price ?? undefined, + minPrice: params.min_price ?? undefined, + conditions: params.condition.length > 0 ? params.condition : undefined, + categoryId: params.category_id ?? undefined, + } + } + return { query, results, @@ -292,10 +320,12 @@ export const useSearchStore = defineStore('search', () => { loading, enriching, error, + filters, search, cancelSearch, enrichSeller, closeUpdates, clearResults, + populateFromLLM, } }) diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts index 2cf32ec..570d0e2 100644 --- a/web/src/stores/session.ts +++ b/web/src/stores/session.ts @@ -11,6 +11,7 @@ export interface SessionFeatures { photo_analysis: boolean shared_scammer_db: boolean shared_image_db: boolean + llm_query_builder: boolean } const LOCAL_FEATURES: SessionFeatures = { @@ -22,6 +23,7 @@ const LOCAL_FEATURES: SessionFeatures = { photo_analysis: true, shared_scammer_db: true, shared_image_db: true, + llm_query_builder: true, } export const useSessionStore = defineStore('session', () => { From b143962ef67915730fa27ae271bd9ae3a9d76df6 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 14 Apr 2026 11:49:48 -0700 Subject: [PATCH 11/13] feat: useLLMQueryBuilder composable with buildQuery, autoRun, and status tracking --- web/src/composables/useLLMQueryBuilder.ts | 92 +++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 web/src/composables/useLLMQueryBuilder.ts diff --git a/web/src/composables/useLLMQueryBuilder.ts b/web/src/composables/useLLMQueryBuilder.ts new file mode 100644 index 0000000..f769d1e --- /dev/null +++ b/web/src/composables/useLLMQueryBuilder.ts @@ -0,0 +1,92 @@ +// web/src/composables/useLLMQueryBuilder.ts +// BSL 1.1 License +/** + * State and API call logic for the LLM query builder panel. + */ +import { ref } from 'vue' +import { useSearchStore, type SearchParamsResult } from '@/stores/search' + +export type BuildStatus = 'idle' | 'thinking' | 'done' | 'error' + +const LS_AUTORUN_KEY = 'snipe:llm-autorun' + +// Module-level refs so state persists across component re-renders +const isOpen = ref(false) +const isLoading = ref(false) +const status = ref('idle') +const explanation = ref('') +const error = ref(null) +const autoRun = ref(localStorage.getItem(LS_AUTORUN_KEY) === 'true') + +export function useLLMQueryBuilder() { + const store = useSearchStore() + + function toggle() { + isOpen.value = !isOpen.value + if (!isOpen.value) { + status.value = 'idle' + error.value = null + explanation.value = '' + } + } + + function setAutoRun(value: boolean) { + autoRun.value = value + localStorage.setItem(LS_AUTORUN_KEY, value ? 'true' : 'false') + } + + async function buildQuery(naturalLanguage: string): Promise { + if (!naturalLanguage.trim()) return null + + isLoading.value = true + status.value = 'thinking' + error.value = null + explanation.value = '' + + try { + const resp = await fetch('/api/search/build', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ natural_language: naturalLanguage.trim() }), + }) + + if (!resp.ok) { + const data = await resp.json().catch(() => ({})) + const msg = typeof data.detail === 'string' + ? data.detail + : (data.detail?.message ?? `Server error (${resp.status})`) + throw new Error(msg) + } + + const params: SearchParamsResult = await resp.json() + store.populateFromLLM(params) + explanation.value = params.explanation + status.value = 'done' + + if (autoRun.value) { + await store.search(params.base_query, store.filters.value) + } + + return params + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Something went wrong.' + error.value = msg + status.value = 'error' + return null + } finally { + isLoading.value = false + } + } + + return { + isOpen, + isLoading, + status, + explanation, + error, + autoRun, + toggle, + setAutoRun, + buildQuery, + } +} From 53ede9a4c528e8bd9c199296a2366038bd5140ec Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 14 Apr 2026 11:50:22 -0700 Subject: [PATCH 12/13] feat: LLMQueryPanel collapsible panel with a11y wiring and theme-aware styles --- web/src/components/LLMQueryPanel.vue | 265 +++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 web/src/components/LLMQueryPanel.vue diff --git a/web/src/components/LLMQueryPanel.vue b/web/src/components/LLMQueryPanel.vue new file mode 100644 index 0000000..638bdac --- /dev/null +++ b/web/src/components/LLMQueryPanel.vue @@ -0,0 +1,265 @@ + + +