Spaces:
Sleeping
Sleeping
Upload 22 files
Browse files
README.md
CHANGED
|
@@ -80,10 +80,10 @@ Matching and cache:
|
|
| 80 |
- `MATCHING_RESULT_CACHE_MAX` (default: `500`)
|
| 81 |
- `MATCHING_RESULT_CACHE_TTL_SECONDS` (default: `86400`)
|
| 82 |
|
| 83 |
-
Scraper and planner:
|
| 84 |
-
- `SCRAPER_DEFAULT_STORE` (default: `nike`)
|
| 85 |
-
- `
|
| 86 |
-
- `
|
| 87 |
|
| 88 |
Database path:
|
| 89 |
- `DB_PATH` (optional override)
|
|
@@ -180,4 +180,4 @@ curl -X POST "http://127.0.0.1:7860/classify" \
|
|
| 180 |
|
| 181 |
Expected post-deploy health signal:
|
| 182 |
- `hf_api_configured` should be `"True"` (primary model).
|
| 183 |
-
- `nvidia_api_configured` should be `"True"` (fallback model).
|
|
|
|
| 80 |
- `MATCHING_RESULT_CACHE_MAX` (default: `500`)
|
| 81 |
- `MATCHING_RESULT_CACHE_TTL_SECONDS` (default: `86400`)
|
| 82 |
|
| 83 |
+
Scraper and planner:
|
| 84 |
+
- `SCRAPER_DEFAULT_STORE` (default: `nike`)
|
| 85 |
+
- `SCRAPER_PLANNER_MODEL_ID` (default: `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning`)
|
| 86 |
+
- `SCRAPER_PLANNER_MAX_TOKENS` (default: `800`)
|
| 87 |
|
| 88 |
Database path:
|
| 89 |
- `DB_PATH` (optional override)
|
|
|
|
| 180 |
|
| 181 |
Expected post-deploy health signal:
|
| 182 |
- `hf_api_configured` should be `"True"` (primary model).
|
| 183 |
+
- `nvidia_api_configured` should be `"True"` (fallback model).
|
app.py
CHANGED
|
@@ -246,12 +246,15 @@ def _gap_suggestions(wardrobe: list[dict[str, Any]], occasion: str) -> list[dict
|
|
| 246 |
return suggestions[:4]
|
| 247 |
|
| 248 |
|
| 249 |
-
SCRAPER_OUTPUT_DIR = Path(__file__).resolve().parent / "scraped_json"
|
| 250 |
-
SCRAPER_RUNTIME_RESULTS: dict[str, dict[str, Any]] = {}
|
| 251 |
-
SCRAPER_RUNTIME_LOCK = threading.Lock()
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
|
| 257 |
def _save_scraper_json_payload(prefix: str, payload: dict[str, Any]) -> str:
|
|
@@ -405,8 +408,8 @@ def _run_text_inference_with_model(primary_model_id: str, prompt: str, max_token
|
|
| 405 |
)
|
| 406 |
|
| 407 |
|
| 408 |
-
def
|
| 409 |
-
return _run_text_inference_with_model(
|
| 410 |
|
| 411 |
|
| 412 |
def _normalize_store_name(value: str | None) -> str:
|
|
@@ -454,7 +457,7 @@ def _build_store_search_urls_from_query(
|
|
| 454 |
gender=gender,
|
| 455 |
wardrobe_items=wardrobe_items,
|
| 456 |
requested_category=requested_category,
|
| 457 |
-
# URL generation should follow
|
| 458 |
# GPT OSS remains reserved for post-scrape cleanup only.
|
| 459 |
completion_fn=None,
|
| 460 |
)
|
|
@@ -631,8 +634,8 @@ def _recover_scraper_plan_from_text(
|
|
| 631 |
"style_direction": planning_context.get("style_direction", "occasion-aligned"),
|
| 632 |
"reference_item_ids": planning_context.get("reference_item_ids", []),
|
| 633 |
"query": query,
|
| 634 |
-
"reason": "Recovered
|
| 635 |
-
"source": "
|
| 636 |
}
|
| 637 |
|
| 638 |
|
|
@@ -1290,7 +1293,7 @@ def _fallback_scraper_plan(
|
|
| 1290 |
}
|
| 1291 |
|
| 1292 |
|
| 1293 |
-
def
|
| 1294 |
occasion: str,
|
| 1295 |
gender: str,
|
| 1296 |
preferences: str,
|
|
@@ -1299,7 +1302,7 @@ def _generate_scraper_plan_with_kimi(
|
|
| 1299 |
filters: dict[str, Any],
|
| 1300 |
max_products: int | None,
|
| 1301 |
store: str,
|
| 1302 |
-
|
| 1303 |
) -> dict[str, Any]:
|
| 1304 |
wardrobe_snapshot = _wardrobe_metadata_snapshot()
|
| 1305 |
requested_target = _normalize_target_category(target_category)
|
|
@@ -1325,31 +1328,31 @@ def _generate_scraper_plan_with_kimi(
|
|
| 1325 |
store=store,
|
| 1326 |
)
|
| 1327 |
|
| 1328 |
-
plan_source = "
|
| 1329 |
-
plan_error: str | None = None
|
| 1330 |
-
try:
|
| 1331 |
-
model_text =
|
| 1332 |
parsed = _recover_scraper_plan_from_text(
|
| 1333 |
model_text=model_text,
|
| 1334 |
planning_context=planning_context,
|
| 1335 |
occasion=occasion,
|
| 1336 |
gender=gender,
|
| 1337 |
-
)
|
| 1338 |
-
if not isinstance(parsed, dict) or not parsed:
|
| 1339 |
-
raise NvidiaPayloadError("
|
| 1340 |
-
except Exception as exc:
|
| 1341 |
-
if
|
| 1342 |
-
raise NvidiaPayloadError(f"
|
| 1343 |
-
plan_source = "fallback"
|
| 1344 |
-
plan_error = str(exc)
|
| 1345 |
-
parsed = _fallback_scraper_plan(
|
| 1346 |
planning_context=planning_context,
|
| 1347 |
occasion=occasion,
|
| 1348 |
-
gender=gender,
|
| 1349 |
-
reason=(
|
| 1350 |
-
"Live
|
| 1351 |
-
),
|
| 1352 |
-
)
|
| 1353 |
|
| 1354 |
resolved_target = _normalize_target_category(
|
| 1355 |
parsed.get("target_category") or planning_context.get("resolved_target_category") or requested_target
|
|
@@ -1410,7 +1413,7 @@ def _generate_scraper_plan_with_kimi(
|
|
| 1410 |
)
|
| 1411 |
|
| 1412 |
wardrobe_grounding = str(parsed.get("wardrobe_grounding") or default_grounding)
|
| 1413 |
-
reason = str(parsed.get("reason") or "
|
| 1414 |
|
| 1415 |
recommendation = ScraperRecommendation(
|
| 1416 |
color=color,
|
|
@@ -1606,7 +1609,7 @@ def _build_shopping_suggestions_from_scraper(
|
|
| 1606 |
]
|
| 1607 |
)
|
| 1608 |
|
| 1609 |
-
runtime_payload =
|
| 1610 |
occasion=occasion,
|
| 1611 |
gender=gender_preference,
|
| 1612 |
preferences=preferences,
|
|
@@ -1633,7 +1636,7 @@ def _build_shopping_suggestions_from_scraper(
|
|
| 1633 |
"image_url": str(product.get("image_url") or ""),
|
| 1634 |
"store": str(runtime_payload.get("store") or store or "nike").title(),
|
| 1635 |
"match_score": max(65, 95 - index * 4),
|
| 1636 |
-
"reason": str(product.get("reason") or query_plan.get("reason") or "
|
| 1637 |
"product_category": str(query_plan.get("category") or "shopping"),
|
| 1638 |
"color": str(query_plan.get("color") or "black"),
|
| 1639 |
"pattern": "solid",
|
|
@@ -1719,11 +1722,11 @@ NVIDIA_MAX_RETRIES = int(os.getenv("NVIDIA_MAX_RETRIES", "3"))
|
|
| 1719 |
NVIDIA_RETRY_BACKOFF_SECONDS = float(os.getenv("NVIDIA_RETRY_BACKOFF_SECONDS", "0.8"))
|
| 1720 |
NVIDIA_ENABLE_THINKING = str(os.getenv("NVIDIA_ENABLE_THINKING", "false")).strip().lower() == "true"
|
| 1721 |
NVIDIA_IMAGE_MAX_DIM = int(os.getenv("NVIDIA_IMAGE_MAX_DIM", "1400"))
|
| 1722 |
-
NVIDIA_FALLBACK_MODEL_IDS = [
|
| 1723 |
-
model_id.strip()
|
| 1724 |
-
for model_id in os.getenv("NVIDIA_FALLBACK_MODEL_IDS", "
|
| 1725 |
-
if model_id.strip()
|
| 1726 |
-
]
|
| 1727 |
NVIDIA_API_KEY_MISSING_DETAIL = "NVIDIA_API_KEY is not configured on this Space."
|
| 1728 |
|
| 1729 |
|
|
@@ -1778,7 +1781,7 @@ OUTFIT_ANCHOR_MIN_SCORE = int(os.getenv("OUTFIT_ANCHOR_MIN_SCORE", "45"))
|
|
| 1778 |
OUTFIT_TEXT_PRESELECT_ENABLED = str(os.getenv("OUTFIT_TEXT_PRESELECT_ENABLED", "false")).strip().lower() == "true"
|
| 1779 |
OUTFIT_TEXT_SELECTOR_MAX_TOKENS = int(os.getenv("OUTFIT_TEXT_SELECTOR_MAX_TOKENS", "400"))
|
| 1780 |
OUTFIT_AI_MAX_TOKENS = int(os.getenv("OUTFIT_AI_MAX_TOKENS", "1200"))
|
| 1781 |
-
OUTFIT_TEXT_SELECTOR_NAME = "
|
| 1782 |
OUTFIT_AI_SCORER_NAME = "ai-grid-v1"
|
| 1783 |
OUTFIT_FALLBACK_SCORER_NAME = "fallback-current-v1"
|
| 1784 |
OUTFIT_GRID_SCORING_PROMPT_TEMPLATE = """You are an expert multimodal outfit matching engine.
|
|
@@ -3050,7 +3053,7 @@ def health() -> dict[str, str]:
|
|
| 3050 |
"nvidia_api_configured": str(bool(_nvidia_api_key())),
|
| 3051 |
"nvidia_invoke_url": NVIDIA_INVOKE_URL,
|
| 3052 |
"engine_version": "scoring-v2",
|
| 3053 |
-
"outfit_matching_provider": "
|
| 3054 |
}
|
| 3055 |
|
| 3056 |
|
|
@@ -3187,7 +3190,7 @@ def scraper_recommend(payload: dict[str, Any] = Body(default_factory=dict)) -> d
|
|
| 3187 |
raise HTTPException(status_code=400, detail="max_products must be at least 1")
|
| 3188 |
|
| 3189 |
try:
|
| 3190 |
-
return
|
| 3191 |
occasion=occasion,
|
| 3192 |
gender=gender,
|
| 3193 |
preferences=preferences,
|
|
@@ -3196,7 +3199,7 @@ def scraper_recommend(payload: dict[str, Any] = Body(default_factory=dict)) -> d
|
|
| 3196 |
filters=filters,
|
| 3197 |
max_products=max_products,
|
| 3198 |
store=store,
|
| 3199 |
-
|
| 3200 |
)
|
| 3201 |
except NvidiaGatewayError as exc:
|
| 3202 |
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
@@ -3256,7 +3259,7 @@ def scraper_page() -> Response:
|
|
| 3256 |
<div class="hero">
|
| 3257 |
<div class="card">
|
| 3258 |
<h1>Wardrobe Assistant Child</h1>
|
| 3259 |
-
<p>
|
| 3260 |
<div class="grid">
|
| 3261 |
<div>
|
| 3262 |
<label for="occasion">Occasion</label>
|
|
@@ -3286,18 +3289,18 @@ def scraper_page() -> Response:
|
|
| 3286 |
</div>
|
| 3287 |
<label for="preferences">Other Preferences</label>
|
| 3288 |
<textarea id="preferences" placeholder="Example: formal office look, breathable fabric, neutral tones, regular fit, avoid oversized silhouettes"></textarea>
|
| 3289 |
-
<button id="runBtn">Generate
|
| 3290 |
<div id="status" class="status"></div>
|
| 3291 |
</div>
|
| 3292 |
<div class="card">
|
| 3293 |
<h2 style="margin-top:0;">Wardrobe Metadata Snapshot</h2>
|
| 3294 |
-
<p class="muted">Current items loaded from the database are used by
|
| 3295 |
<pre id="wardrobeSnapshot">{wardrobe_json}</pre>
|
| 3296 |
</div>
|
| 3297 |
</div>
|
| 3298 |
|
| 3299 |
<div class="card" style="margin-top:16px;">
|
| 3300 |
-
<h2 style="margin-top:0;">
|
| 3301 |
<pre id="queryPlan">Run the search to generate a wardrobe-aware query.</pre>
|
| 3302 |
</div>
|
| 3303 |
|
|
@@ -3341,7 +3344,7 @@ def scraper_page() -> Response:
|
|
| 3341 |
}}
|
| 3342 |
|
| 3343 |
runBtn.addEventListener('click', async () => {{
|
| 3344 |
-
statusEl.textContent = 'Generating query with
|
| 3345 |
productsEl.innerHTML = '';
|
| 3346 |
try {{
|
| 3347 |
const payload = {{
|
|
|
|
| 246 |
return suggestions[:4]
|
| 247 |
|
| 248 |
|
| 249 |
+
SCRAPER_OUTPUT_DIR = Path(__file__).resolve().parent / "scraped_json"
|
| 250 |
+
SCRAPER_RUNTIME_RESULTS: dict[str, dict[str, Any]] = {}
|
| 251 |
+
SCRAPER_RUNTIME_LOCK = threading.Lock()
|
| 252 |
+
SCRAPER_PLANNER_MODEL_ID = os.getenv(
|
| 253 |
+
"SCRAPER_PLANNER_MODEL_ID",
|
| 254 |
+
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning",
|
| 255 |
+
)
|
| 256 |
+
SCRAPER_PLANNER_MAX_TOKENS = int(os.getenv("SCRAPER_PLANNER_MAX_TOKENS", "800"))
|
| 257 |
+
SCRAPER_DEFAULT_STORE = str(os.getenv("SCRAPER_DEFAULT_STORE", "nike")).strip().lower()
|
| 258 |
|
| 259 |
|
| 260 |
def _save_scraper_json_payload(prefix: str, payload: dict[str, Any]) -> str:
|
|
|
|
| 408 |
)
|
| 409 |
|
| 410 |
|
| 411 |
+
def run_scraper_planner_text_inference(prompt: str, max_tokens: int = SCRAPER_PLANNER_MAX_TOKENS) -> str:
|
| 412 |
+
return _run_text_inference_with_model(SCRAPER_PLANNER_MODEL_ID, prompt, max_tokens)
|
| 413 |
|
| 414 |
|
| 415 |
def _normalize_store_name(value: str | None) -> str:
|
|
|
|
| 457 |
gender=gender,
|
| 458 |
wardrobe_items=wardrobe_items,
|
| 459 |
requested_category=requested_category,
|
| 460 |
+
# URL generation should follow planner output + deterministic rules.
|
| 461 |
# GPT OSS remains reserved for post-scrape cleanup only.
|
| 462 |
completion_fn=None,
|
| 463 |
)
|
|
|
|
| 634 |
"style_direction": planning_context.get("style_direction", "occasion-aligned"),
|
| 635 |
"reference_item_ids": planning_context.get("reference_item_ids", []),
|
| 636 |
"query": query,
|
| 637 |
+
"reason": "Recovered Nemotron planner output from semi-structured response.",
|
| 638 |
+
"source": "nemotron",
|
| 639 |
}
|
| 640 |
|
| 641 |
|
|
|
|
| 1293 |
}
|
| 1294 |
|
| 1295 |
|
| 1296 |
+
def _generate_scraper_plan_with_nemotron(
|
| 1297 |
occasion: str,
|
| 1298 |
gender: str,
|
| 1299 |
preferences: str,
|
|
|
|
| 1302 |
filters: dict[str, Any],
|
| 1303 |
max_products: int | None,
|
| 1304 |
store: str,
|
| 1305 |
+
strict_nemotron: bool = False,
|
| 1306 |
) -> dict[str, Any]:
|
| 1307 |
wardrobe_snapshot = _wardrobe_metadata_snapshot()
|
| 1308 |
requested_target = _normalize_target_category(target_category)
|
|
|
|
| 1328 |
store=store,
|
| 1329 |
)
|
| 1330 |
|
| 1331 |
+
plan_source = "nemotron"
|
| 1332 |
+
plan_error: str | None = None
|
| 1333 |
+
try:
|
| 1334 |
+
model_text = run_scraper_planner_text_inference(prompt, max_tokens=SCRAPER_PLANNER_MAX_TOKENS)
|
| 1335 |
parsed = _recover_scraper_plan_from_text(
|
| 1336 |
model_text=model_text,
|
| 1337 |
planning_context=planning_context,
|
| 1338 |
occasion=occasion,
|
| 1339 |
gender=gender,
|
| 1340 |
+
)
|
| 1341 |
+
if not isinstance(parsed, dict) or not parsed:
|
| 1342 |
+
raise NvidiaPayloadError("Nemotron scraper planner returned empty or invalid JSON payload.")
|
| 1343 |
+
except Exception as exc:
|
| 1344 |
+
if strict_nemotron:
|
| 1345 |
+
raise NvidiaPayloadError(f"Nemotron planner unavailable: {exc}") from exc
|
| 1346 |
+
plan_source = "fallback"
|
| 1347 |
+
plan_error = str(exc)
|
| 1348 |
+
parsed = _fallback_scraper_plan(
|
| 1349 |
planning_context=planning_context,
|
| 1350 |
occasion=occasion,
|
| 1351 |
+
gender=gender,
|
| 1352 |
+
reason=(
|
| 1353 |
+
"Live Nemotron query planning was unavailable, so a deterministic fallback planner was used."
|
| 1354 |
+
),
|
| 1355 |
+
)
|
| 1356 |
|
| 1357 |
resolved_target = _normalize_target_category(
|
| 1358 |
parsed.get("target_category") or planning_context.get("resolved_target_category") or requested_target
|
|
|
|
| 1413 |
)
|
| 1414 |
|
| 1415 |
wardrobe_grounding = str(parsed.get("wardrobe_grounding") or default_grounding)
|
| 1416 |
+
reason = str(parsed.get("reason") or "Nemotron generated a wardrobe-aware shopping query.")
|
| 1417 |
|
| 1418 |
recommendation = ScraperRecommendation(
|
| 1419 |
color=color,
|
|
|
|
| 1609 |
]
|
| 1610 |
)
|
| 1611 |
|
| 1612 |
+
runtime_payload = _generate_scraper_plan_with_nemotron(
|
| 1613 |
occasion=occasion,
|
| 1614 |
gender=gender_preference,
|
| 1615 |
preferences=preferences,
|
|
|
|
| 1636 |
"image_url": str(product.get("image_url") or ""),
|
| 1637 |
"store": str(runtime_payload.get("store") or store or "nike").title(),
|
| 1638 |
"match_score": max(65, 95 - index * 4),
|
| 1639 |
+
"reason": str(product.get("reason") or query_plan.get("reason") or "Nemotron generated a wardrobe-aware shopping query."),
|
| 1640 |
"product_category": str(query_plan.get("category") or "shopping"),
|
| 1641 |
"color": str(query_plan.get("color") or "black"),
|
| 1642 |
"pattern": "solid",
|
|
|
|
| 1722 |
NVIDIA_RETRY_BACKOFF_SECONDS = float(os.getenv("NVIDIA_RETRY_BACKOFF_SECONDS", "0.8"))
|
| 1723 |
NVIDIA_ENABLE_THINKING = str(os.getenv("NVIDIA_ENABLE_THINKING", "false")).strip().lower() == "true"
|
| 1724 |
NVIDIA_IMAGE_MAX_DIM = int(os.getenv("NVIDIA_IMAGE_MAX_DIM", "1400"))
|
| 1725 |
+
NVIDIA_FALLBACK_MODEL_IDS = [
|
| 1726 |
+
model_id.strip()
|
| 1727 |
+
for model_id in os.getenv("NVIDIA_FALLBACK_MODEL_IDS", "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning").split(",")
|
| 1728 |
+
if model_id.strip()
|
| 1729 |
+
]
|
| 1730 |
NVIDIA_API_KEY_MISSING_DETAIL = "NVIDIA_API_KEY is not configured on this Space."
|
| 1731 |
|
| 1732 |
|
|
|
|
| 1781 |
OUTFIT_TEXT_PRESELECT_ENABLED = str(os.getenv("OUTFIT_TEXT_PRESELECT_ENABLED", "false")).strip().lower() == "true"
|
| 1782 |
OUTFIT_TEXT_SELECTOR_MAX_TOKENS = int(os.getenv("OUTFIT_TEXT_SELECTOR_MAX_TOKENS", "400"))
|
| 1783 |
OUTFIT_AI_MAX_TOKENS = int(os.getenv("OUTFIT_AI_MAX_TOKENS", "1200"))
|
| 1784 |
+
OUTFIT_TEXT_SELECTOR_NAME = "nemotron-text-preselect-v1"
|
| 1785 |
OUTFIT_AI_SCORER_NAME = "ai-grid-v1"
|
| 1786 |
OUTFIT_FALLBACK_SCORER_NAME = "fallback-current-v1"
|
| 1787 |
OUTFIT_GRID_SCORING_PROMPT_TEMPLATE = """You are an expert multimodal outfit matching engine.
|
|
|
|
| 3053 |
"nvidia_api_configured": str(bool(_nvidia_api_key())),
|
| 3054 |
"nvidia_invoke_url": NVIDIA_INVOKE_URL,
|
| 3055 |
"engine_version": "scoring-v2",
|
| 3056 |
+
"outfit_matching_provider": "nemotron",
|
| 3057 |
}
|
| 3058 |
|
| 3059 |
|
|
|
|
| 3190 |
raise HTTPException(status_code=400, detail="max_products must be at least 1")
|
| 3191 |
|
| 3192 |
try:
|
| 3193 |
+
return _generate_scraper_plan_with_nemotron(
|
| 3194 |
occasion=occasion,
|
| 3195 |
gender=gender,
|
| 3196 |
preferences=preferences,
|
|
|
|
| 3199 |
filters=filters,
|
| 3200 |
max_products=max_products,
|
| 3201 |
store=store,
|
| 3202 |
+
strict_nemotron=True,
|
| 3203 |
)
|
| 3204 |
except NvidiaGatewayError as exc:
|
| 3205 |
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
|
|
| 3259 |
<div class="hero">
|
| 3260 |
<div class="card">
|
| 3261 |
<h1>Wardrobe Assistant Child</h1>
|
| 3262 |
+
<p>Nemotron reads wardrobe metadata, builds a context-aware shopping query, and returns matching products with links, names, prices, and images.</p>
|
| 3263 |
<div class="grid">
|
| 3264 |
<div>
|
| 3265 |
<label for="occasion">Occasion</label>
|
|
|
|
| 3289 |
</div>
|
| 3290 |
<label for="preferences">Other Preferences</label>
|
| 3291 |
<textarea id="preferences" placeholder="Example: formal office look, breathable fabric, neutral tones, regular fit, avoid oversized silhouettes"></textarea>
|
| 3292 |
+
<button id="runBtn">Generate Nemotron Query and Scrape</button>
|
| 3293 |
<div id="status" class="status"></div>
|
| 3294 |
</div>
|
| 3295 |
<div class="card">
|
| 3296 |
<h2 style="margin-top:0;">Wardrobe Metadata Snapshot</h2>
|
| 3297 |
+
<p class="muted">Current items loaded from the database are used by Nemotron to shape the shopping query.</p>
|
| 3298 |
<pre id="wardrobeSnapshot">{wardrobe_json}</pre>
|
| 3299 |
</div>
|
| 3300 |
</div>
|
| 3301 |
|
| 3302 |
<div class="card" style="margin-top:16px;">
|
| 3303 |
+
<h2 style="margin-top:0;">Nemotron Query Plan</h2>
|
| 3304 |
<pre id="queryPlan">Run the search to generate a wardrobe-aware query.</pre>
|
| 3305 |
</div>
|
| 3306 |
|
|
|
|
| 3344 |
}}
|
| 3345 |
|
| 3346 |
runBtn.addEventListener('click', async () => {{
|
| 3347 |
+
statusEl.textContent = 'Generating query with Nemotron and scraping products...';
|
| 3348 |
productsEl.innerHTML = '';
|
| 3349 |
try {{
|
| 3350 |
const payload = {{
|