Compare commits

...

14 commits

Author SHA1 Message Date
0919ebc76a feat: LLM query builder — natural language → eBay search filters (snipe#29)
Some checks are pending
CI / Python tests (push) Waiting to run
CI / Frontend typecheck + tests (push) Waiting to run
Mirror / mirror (push) Waiting to run
2026-04-14 12:01:31 -07:00
37e34ac820 feat: mount LLMQueryPanel in SearchView and add auto-run setting to SettingsView 2026-04-14 11:50:57 -07:00
53ede9a4c5 feat: LLMQueryPanel collapsible panel with a11y wiring and theme-aware styles 2026-04-14 11:50:22 -07:00
b143962ef6 feat: useLLMQueryBuilder composable with buildQuery, autoRun, and status tracking 2026-04-14 11:49:48 -07:00
65ddd2f4fa feat: add llm_query_builder to SessionFeatures and populateFromLLM to search store 2026-04-14 11:49:22 -07:00
cdc4e40775 feat: POST /api/search/build endpoint with tier gate and category cache wiring 2026-04-14 11:46:15 -07:00
93f989c821 feat: add llm_query_builder tier gate (paid+) to tiers.py and SessionFeatures 2026-04-14 11:44:53 -07:00
7720f1def5 feat: QueryTranslator with domain-aware system prompt and category hint injection 2026-04-14 11:43:19 -07:00
3c54a65dda feat: SearchParamsResponse dataclass and JSON parser for LLM query builder 2026-04-14 11:40:44 -07:00
15718ab431 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).
2026-04-14 11:38:12 -07:00
0b8cb63968 feat: EbayCategoryCache refresh from eBay Taxonomy API with bootstrap fallback 2026-04-14 11:14:52 -07:00
7c73186394 feat: EbayCategoryCache get_relevant and get_all_for_prompt 2026-04-14 11:06:13 -07:00
099943b50b feat: EbayCategoryCache init, is_stale, bootstrap seed 2026-04-14 10:49:17 -07:00
254fc482db feat: add ebay_categories migration for LLM query builder category cache 2026-04-14 10:40:20 -07:00
17 changed files with 1441 additions and 0 deletions

View file

@ -69,6 +69,7 @@ class SessionFeatures:
photo_analysis: bool photo_analysis: bool
shared_scammer_db: bool shared_scammer_db: bool
shared_image_db: bool shared_image_db: bool
llm_query_builder: bool
def compute_features(tier: str) -> SessionFeatures: def compute_features(tier: str) -> SessionFeatures:
@ -85,6 +86,7 @@ def compute_features(tier: str) -> SessionFeatures:
photo_analysis=paid_plus, photo_analysis=paid_plus,
shared_scammer_db=paid_plus, shared_scammer_db=paid_plus,
shared_image_db=paid_plus, shared_image_db=paid_plus,
llm_query_builder=paid_plus,
) )

View file

@ -57,6 +57,15 @@ def _get_community_store() -> "SnipeCommunityStore | None":
return _community_store 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 @asynccontextmanager
async def _lifespan(app: FastAPI): async def _lifespan(app: FastAPI):
global _community_store global _community_store
@ -84,6 +93,34 @@ async def _lifespan(app: FastAPI):
else: else:
log.debug("COMMUNITY_DB_URL not set — community trust signals disabled.") 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 yield
get_scheduler(sched_db).shutdown(timeout=10.0) get_scheduler(sched_db).shutdown(timeout=10.0)
@ -968,3 +1005,62 @@ def patch_preference(
return store.get_all_preferences() 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,
}

View file

@ -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);

5
app/llm/__init__.py Normal file
View file

@ -0,0 +1,5 @@
# app/llm/__init__.py
# BSL 1.1 License
from .query_translator import QueryTranslator, QueryTranslatorError, SearchParamsResponse
__all__ = ["QueryTranslator", "QueryTranslatorError", "SearchParamsResponse"]

167
app/llm/query_translator.py Normal file
View file

@ -0,0 +1,167 @@
# 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 TYPE_CHECKING, Optional
if TYPE_CHECKING:
from app.platforms.ebay.categories import EbayCategoryCache
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,
)
# ── 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:
"""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)

View file

@ -0,0 +1,254 @@
# 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
import requests
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))
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()]
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]
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),
)
# 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:
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)

View file

@ -26,6 +26,7 @@ FEATURES: dict[str, str] = {
"reverse_image_search": "paid", "reverse_image_search": "paid",
"ebay_oauth": "paid", # full trust scores via eBay Trading API "ebay_oauth": "paid", # full trust scores via eBay Trading API
"background_monitoring": "paid", # limited at Paid; see LIMITS below "background_monitoring": "paid", # limited at Paid; see LIMITS below
"llm_query_builder": "paid", # inline natural-language → filter translator
# Premium tier # Premium tier
"auto_bidding": "premium", "auto_bidding": "premium",

View file

@ -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"]

View file

@ -0,0 +1,218 @@
"""Unit tests for EbayCategoryCache."""
from __future__ import annotations
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
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
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)
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
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

View file

@ -0,0 +1,170 @@
"""Unit tests for QueryTranslator — LLMRouter mocked at boundary."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from app.llm.query_translator import QueryTranslator, 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)
# ── 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")

View file

@ -22,3 +22,13 @@ def test_saved_searches_are_free():
# Ungated: retention feature — friction cost outweighs gate value (see tiers.py) # 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="free") is True
assert can_use("saved_searches", tier="paid") 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

View file

@ -0,0 +1,265 @@
<!-- web/src/components/LLMQueryPanel.vue -->
<!-- BSL 1.1 License -->
<template>
<div class="llm-panel-wrapper">
<button
type="button"
class="llm-panel-toggle"
:class="{ 'llm-panel-toggle--open': isOpen }"
:aria-expanded="String(isOpen)"
aria-controls="llm-panel"
@click="toggle"
>
<span class="llm-panel-toggle__icon" aria-hidden="true"></span>
Build with AI
<span class="llm-panel-toggle__chevron" aria-hidden="true">{{ isOpen ? '▲' : '▾' }}</span>
</button>
<section
id="llm-panel"
class="llm-panel"
:class="{ 'llm-panel--open': isOpen }"
:hidden="!isOpen"
>
<label for="llm-input" class="llm-panel__label">
Describe what you're looking for
</label>
<textarea
id="llm-input"
ref="textareaRef"
v-model="inputText"
class="llm-panel__textarea"
rows="2"
placeholder="e.g. used RTX 3080 under $300, no mining cards or for-parts listings"
:disabled="isLoading"
@keydown.escape.prevent="handleEscape"
@keydown.ctrl.enter.prevent="onBuild"
/>
<div class="llm-panel__actions">
<button
type="button"
class="llm-panel__build-btn"
:disabled="isLoading || !inputText.trim()"
@click="onBuild"
>
{{ isLoading ? 'Building…' : 'Build Search' }}
</button>
<span
role="status"
aria-live="polite"
class="llm-panel__status-pill"
:class="`llm-panel__status-pill--${status}`"
>
<span v-if="status === 'thinking'">
<span class="llm-panel__spinner" aria-hidden="true" />
Thinking
</span>
<span v-else-if="status === 'done'">Filters updated</span>
<span v-else-if="status === 'error'">Error</span>
</span>
</div>
<p v-if="error" class="llm-panel__error" role="alert">
{{ error }}
</p>
<p v-if="status === 'done' && explanation" class="llm-panel__explanation">
{{ explanation }}
</p>
<label class="llm-panel__autorun">
<input
type="checkbox"
:checked="autoRun"
@change="setAutoRun(($event.target as HTMLInputElement).checked)"
/>
Run search automatically after building
</label>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, watch } from 'vue'
import { useLLMQueryBuilder } from '@/composables/useLLMQueryBuilder'
const {
isOpen,
isLoading,
status,
explanation,
error,
autoRun,
toggle,
setAutoRun,
buildQuery,
} = useLLMQueryBuilder()
const inputText = ref('')
const textareaRef = ref<HTMLTextAreaElement | null>(null)
watch(isOpen, async (open) => {
if (open) {
await nextTick()
textareaRef.value?.focus()
}
})
async function onBuild() {
await buildQuery(inputText.value)
}
function handleEscape() {
toggle()
const toggleBtn = document.querySelector<HTMLButtonElement>('[aria-controls="llm-panel"]')
toggleBtn?.focus()
}
</script>
<style scoped>
.llm-panel-wrapper {
width: 100%;
}
.llm-panel-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
border-radius: var(--radius-md);
color: var(--color-accent);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
margin-bottom: var(--space-2);
}
.llm-panel-toggle:hover,
.llm-panel-toggle--open {
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
border-color: var(--color-accent);
}
.llm-panel {
display: none;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
}
.llm-panel--open {
display: flex;
}
.llm-panel__label {
font-size: 0.85rem;
font-weight: 500;
color: var(--color-text-muted);
}
.llm-panel__textarea {
width: 100%;
padding: var(--space-2) var(--space-3);
background: var(--color-surface-1);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
font-size: 0.9rem;
resize: vertical;
font-family: inherit;
}
.llm-panel__textarea:focus {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
}
.llm-panel__actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.llm-panel__build-btn {
padding: var(--space-2) var(--space-4);
background: var(--color-accent);
color: #fff;
border: none;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
}
.llm-panel__build-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.llm-panel__status-pill {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.llm-panel__status-pill--idle {
visibility: hidden;
}
.llm-panel__status-pill--done {
color: var(--color-success);
}
.llm-panel__status-pill--error {
color: var(--color-error);
}
@media (prefers-reduced-motion: no-preference) {
.llm-panel__spinner {
display: inline-block;
width: 0.75em;
height: 0.75em;
border: 2px solid var(--color-accent);
border-top-color: transparent;
border-radius: 50%;
animation: llm-spin 0.7s linear infinite;
vertical-align: middle;
margin-right: 0.25em;
}
}
@keyframes llm-spin {
to { transform: rotate(360deg); }
}
.llm-panel__error {
font-size: 0.85rem;
color: var(--color-error);
margin: 0;
}
.llm-panel__explanation {
font-size: 0.85rem;
color: var(--color-text-muted);
margin: 0;
font-style: italic;
}
.llm-panel__autorun {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.8rem;
color: var(--color-text-muted);
cursor: pointer;
}
</style>

View file

@ -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<BuildStatus>('idle')
const explanation = ref<string>('')
const error = ref<string | null>(null)
const autoRun = ref<boolean>(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<SearchParamsResult | null> {
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,
}
}

View file

@ -61,6 +61,18 @@ export interface SavedSearch {
last_run_at: string | null 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 { export interface SearchFilters {
minTrustScore?: number minTrustScore?: number
minPrice?: number minPrice?: number
@ -120,6 +132,7 @@ export const useSearchStore = defineStore('search', () => {
) )
const marketPrice = ref<number | null>(cached?.marketPrice ?? null) const marketPrice = ref<number | null>(cached?.marketPrice ?? null)
const adapterUsed = ref<'api' | 'scraper' | null>(cached?.adapterUsed ?? null) const adapterUsed = ref<'api' | 'scraper' | null>(cached?.adapterUsed ?? null)
const filters = ref<SearchFilters>({})
const affiliateActive = ref<boolean>(false) const affiliateActive = ref<boolean>(false)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@ -281,6 +294,21 @@ export const useSearchStore = defineStore('search', () => {
error.value = null 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 { return {
query, query,
results, results,
@ -292,10 +320,12 @@ export const useSearchStore = defineStore('search', () => {
loading, loading,
enriching, enriching,
error, error,
filters,
search, search,
cancelSearch, cancelSearch,
enrichSeller, enrichSeller,
closeUpdates, closeUpdates,
clearResults, clearResults,
populateFromLLM,
} }
}) })

View file

@ -11,6 +11,7 @@ export interface SessionFeatures {
photo_analysis: boolean photo_analysis: boolean
shared_scammer_db: boolean shared_scammer_db: boolean
shared_image_db: boolean shared_image_db: boolean
llm_query_builder: boolean
} }
const LOCAL_FEATURES: SessionFeatures = { const LOCAL_FEATURES: SessionFeatures = {
@ -22,6 +23,7 @@ const LOCAL_FEATURES: SessionFeatures = {
photo_analysis: true, photo_analysis: true,
shared_scammer_db: true, shared_scammer_db: true,
shared_image_db: true, shared_image_db: true,
llm_query_builder: true,
} }
export const useSessionStore = defineStore('session', () => { export const useSessionStore = defineStore('session', () => {

View file

@ -79,6 +79,9 @@
</form> </form>
</header> </header>
<!-- LLM query builder panel only shown when feature flag is active -->
<LLMQueryPanel v-if="session.features.llm_query_builder" />
<div class="search-body"> <div class="search-body">
<!-- Mobile filter toggle --> <!-- Mobile filter toggle -->
<button <button
@ -450,6 +453,7 @@ import { useSavedSearchesStore } from '../stores/savedSearches'
import { useSessionStore } from '../stores/session' import { useSessionStore } from '../stores/session'
import { useBlocklistStore } from '../stores/blocklist' import { useBlocklistStore } from '../stores/blocklist'
import ListingCard from '../components/ListingCard.vue' import ListingCard from '../components/ListingCard.vue'
import LLMQueryPanel from '../components/LLMQueryPanel.vue'
const route = useRoute() const route = useRoute()
const store = useSearchStore() const store = useSearchStore()

View file

@ -79,6 +79,30 @@
<p v-if="prefs.error" class="settings-error" role="alert">{{ prefs.error }}</p> <p v-if="prefs.error" class="settings-error" role="alert">{{ prefs.error }}</p>
</section> </section>
<section class="settings-section">
<h2 class="settings-section-title">Search</h2>
<label class="settings-toggle">
<div class="settings-toggle-text">
<span class="settings-toggle-label">Auto-run after Build with AI</span>
<span class="settings-toggle-desc">
When enabled, Snipe starts searching immediately after the AI fills in your filters.
Disable to review the filters before searching.
</span>
</div>
<button
class="toggle-btn"
:class="{ 'toggle-btn--on': llmAutoRun }"
:aria-pressed="String(llmAutoRun)"
aria-label="Run search automatically after AI builds filters"
@click="setLLMAutoRun(!llmAutoRun)"
>
<span class="toggle-btn__track" />
<span class="toggle-btn__thumb" />
</button>
</label>
</section>
</div> </div>
</template> </template>
@ -87,10 +111,12 @@ import { ref, watch } from 'vue'
import { useTrustSignalPref } from '../composables/useTrustSignalPref' import { useTrustSignalPref } from '../composables/useTrustSignalPref'
import { useSessionStore } from '../stores/session' import { useSessionStore } from '../stores/session'
import { usePreferencesStore } from '../stores/preferences' import { usePreferencesStore } from '../stores/preferences'
import { useLLMQueryBuilder } from '../composables/useLLMQueryBuilder'
const { enabled: trustSignalEnabled, setEnabled } = useTrustSignalPref() const { enabled: trustSignalEnabled, setEnabled } = useTrustSignalPref()
const session = useSessionStore() const session = useSessionStore()
const prefs = usePreferencesStore() const prefs = usePreferencesStore()
const { autoRun: llmAutoRun, setAutoRun: setLLMAutoRun } = useLLMQueryBuilder()
// Local input buffer for BYOK ID synced from store, saved on blur/enter // Local input buffer for BYOK ID synced from store, saved on blur/enter
const byokInput = ref(prefs.affiliateByokId) const byokInput = ref(prefs.affiliateByokId)