Compare commits
14 commits
979ddd8cce
...
0919ebc76a
| Author | SHA1 | Date | |
|---|---|---|---|
| 0919ebc76a | |||
| 37e34ac820 | |||
| 53ede9a4c5 | |||
| b143962ef6 | |||
| 65ddd2f4fa | |||
| cdc4e40775 | |||
| 93f989c821 | |||
| 7720f1def5 | |||
| 3c54a65dda | |||
| 15718ab431 | |||
| 0b8cb63968 | |||
| 7c73186394 | |||
| 099943b50b | |||
| 254fc482db |
17 changed files with 1441 additions and 0 deletions
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
96
api/main.py
96
api/main.py
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
16
app/db/migrations/011_ebay_categories.sql
Normal file
16
app/db/migrations/011_ebay_categories.sql
Normal 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
5
app/llm/__init__.py
Normal 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
167
app/llm/query_translator.py
Normal 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)
|
||||||
254
app/platforms/ebay/categories.py
Normal file
254
app/platforms/ebay/categories.py
Normal 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)
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
83
tests/test_api_search_build.py
Normal file
83
tests/test_api_search_build.py
Normal 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"]
|
||||||
218
tests/test_ebay_categories.py
Normal file
218
tests/test_ebay_categories.py
Normal 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
|
||||||
170
tests/test_query_translator.py
Normal file
170
tests/test_query_translator.py
Normal 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")
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
265
web/src/components/LLMQueryPanel.vue
Normal file
265
web/src/components/LLMQueryPanel.vue
Normal 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>
|
||||||
92
web/src/composables/useLLMQueryBuilder.ts
Normal file
92
web/src/composables/useLLMQueryBuilder.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue