diff --git a/backend-py/app/ai.py b/backend-py/app/ai.py index ed7e34a..acebc07 100644 --- a/backend-py/app/ai.py +++ b/backend-py/app/ai.py @@ -94,21 +94,25 @@ SYSTEM_PROMPT_PICKER = ( " «Инвертор (тише и экономия ~30% электричества)»\n\n" "═══ ФОРМАТ ОТВЕТА ═══\n" "Возвращай **3–5 моделей по КАЖДОЙ категории** (не одну!) — для клиента это выбор.\n" + "Каждая модель ДОЛЖНА содержать аналитику: pros (минимум 3), cons (минимум 2), почему выбрана, с чем сравнивать.\n" + "По КАЖДОЙ категории напиши `analysis` — обзор: какие компромиссы, на что обратить внимание.\n" "Валидный JSON без markdown, без ```:\n" "{\n" - ' "summary": "1-2 предложения общего вывода",\n' + ' "summary": "2-3 предложения общего вывода: что подобрали, почему этот набор, на чём сэкономили / куда вложились",\n' ' "by_category": {\n' ' "fridge": {\n' + ' "analysis": "2-3 предложения: какие компромиссы в этой категории, какие модели для каких сценариев, на что смотреть при финальном выборе",\n' ' "models": [\n' ' {\n' - ' "brand": "Bosch",\n' - ' "model": "Serie 4 KGN39NW00R",\n' + ' "brand": "Haier",\n' + ' "model": "C4F744CMG",\n' ' "price_min_rub": 79990,\n' ' "price_max_rub": 92000,\n' - ' "search_query": "Bosch Serie 4 KGN39NW00R холодильник",\n' - ' "highlights": ["NoFrost (не нужно размораживать)", "Инвертор (тише и экономия ~30%)"],\n' - ' "pros": ["тихий 38дБ", "класс A++", "стеклянные полки"],\n' - ' "cons": ["глубина 660мм — на 60мм больше ниши"],\n' + ' "search_query": "Haier C4F744CMG холодильник",\n' + ' "highlights": ["NoFrost (не нужно размораживать)", "Инвертор (тише и -30% энергии)"],\n' + ' "pros": ["тихий 36 дБ — на 4 дБ тише среднего по сегменту", "класс A++, экономия ~30% против A+", "большой объём 463 л против 380 л у конкурентов в той же ценовой категории"],\n' + ' "cons": ["глубина 660 мм — на 60 мм больше стандартной ниши, проверить нишу клиента", "нет зоны свежести BioFresh — в этом плане Liebherr ровно вдвое лучше"],\n' + ' "reasoning": "Лучший выбор по цена/качество в этом бюджете. Тише и больше чем Bosch в той же цене, но без премиум-зоны свежести.",\n' ' "tier": "middle",\n' ' "match_score": 0.92\n' " }\n" @@ -119,13 +123,17 @@ SYSTEM_PROMPT_PICKER = ( ' "budget_status": "в_рамках|превышение|значительно_ниже",\n' ' "client_temperature": "premium|middle|budget|mixed",\n' ' "warnings": [],\n' - ' "next_steps": []\n' + ' "next_steps": ["рекомендации для менеджера: что уточнить с клиентом, что проверить на замере"]\n' "}\n\n" - "ВАЖНО:\n" - "- Не выдумывай артикулы — указывай реальные линейки/индексы (Bosch Serie 4 KGN39NW00R, не «Bosch X-200»)\n" - "- `search_query` — точная строка для поиска модели на маркетплейсе (бренд + индекс + категория)\n" - "- Если клиент выбрал brand_strategy='single' с конкретной маркой — ВСЕ models в каждой категории должны быть из этой марки\n" - "- price_min_rub / price_max_rub — диапазон цен по разным магазинам (если не уверен — поставь один и тот же)" + "═══ КРИТИЧНО ═══\n" + "1. **Реальные модели**: артикулы должны существовать в природе (Haier C4F744CMG, Bosch Serie 4 KGN39NW00R, Liebherr CNd 5223 — НЕ «Bosch X-200» и НЕ «Haier выгодный»).\n" + "2. **РЕАЛИИ РФ 2026**: Bosch/Siemens/Miele идут параллельным импортом — их цена в РФ выше официальных на 15-30%. Учитывай это.\n" + "3. **Pros с числами**: НЕ «тихий» — а «36 дБ». НЕ «энергоэффективный» — а «класс A++, ~30% экономии». НЕ «вместительный» — а «463 л».\n" + "4. **Cons обязательны**: даже у лучших моделей есть недостатки. Если cons пусто — модель не выбрана. Конкретные минусы: габарит больше ниши, шумнее на 2 дБ, без какой-то функции, цена выше на N%, длительная гарантия только N лет.\n" + "5. **Reasoning**: 1 предложение «почему именно эта модель в этом наборе» — позиционирование относительно других в выдаче.\n" + "6. **search_query**: точная строка для поиска (бренд + индекс + слово категория). AI агент будет парсить маркетплейсы по этой строке — не указывай лишнего.\n" + "7. Бренд-стратегия 'single' — ВСЕ models из одной марки.\n" + "8. price_min_rub/price_max_rub — диапазон по разным магазинам (если не уверен — один и тот же)." ) diff --git a/backend-py/app/main.py b/backend-py/app/main.py index a5d82f7..ca8ef35 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -474,21 +474,55 @@ def _format_podbor_for_telegram(ai_result: dict[str, Any], client_name: str, lea for cat_key, cat_data in by_cat.items(): cat_label = _CAT_LABELS.get(cat_key, cat_key.upper()) lines.append(f"━━━ {cat_label} ━━━") + # Анализ категории от AI + analysis = (cat_data or {}).get("analysis") + if analysis: + lines.append(f"{analysis}") + lines.append("") models = (cat_data or {}).get("models") or [] for i, m in enumerate(models, 1): lines.append(f"{i}. {m.get('brand', '')} {m.get('model', '')}") - pmin = m.get("price_min_rub") - pmax = m.get("price_max_rub") + # Цены и магазины из enrichment + enriched = m.get("enriched") or {} + pmin = enriched.get("price_min_rub") or m.get("price_min_rub") + pmax = enriched.get("price_max_rub") or m.get("price_max_rub") if pmin and pmax and pmin != pmax: lines.append(f"💰 {_format_price(pmin)} — {_format_price(pmax)} ₽") elif pmin: lines.append(f"💰 {_format_price(pmin)} ₽") + # Отзывы и рейтинг (если есть) + rating = enriched.get("rating_max") + reviews = enriched.get("reviews_total") + meta_parts = [] + if rating: meta_parts.append(f"★ {rating:.1f}") + if reviews: meta_parts.append(f"{reviews} отзыв.") + stores = enriched.get("stores_count") + if stores: meta_parts.append(f"{stores} магаз.") + if meta_parts: + lines.append("📊 " + " · ".join(meta_parts)) + # Источники где нашли товар + sources_found = [ + src.upper() for src in ("ozon", "citilink", "wb", "yamarket", "dns") + if enriched.get(src) + ] + if sources_found: + lines.append(f"🛒 Нашли в: {' · '.join(sources_found)}") if m.get("highlights"): lines.append("✓ " + ", ".join(m["highlights"])) if m.get("pros"): - lines.append("⊕ " + "; ".join(m["pros"][:3])) + lines.append("⊕ Плюсы:") + for p in m["pros"][:4]: + lines.append(f" • {p}") if m.get("cons"): - lines.append("⊖ " + "; ".join(m["cons"][:2])) + lines.append("⊖ Минусы:") + for c in m["cons"][:3]: + lines.append(f" • {c}") + if m.get("reasoning"): + lines.append(f"💡 {m['reasoning']}") + # Ссылка на «лучший» магазин + best_url = enriched.get("best_url") + if best_url: + lines.append(f"🔗 Открыть в магазине") lines.append("") lines.append("") else: diff --git a/backend-py/app/parsers/wb.py b/backend-py/app/parsers/wb.py index 20be0fa..ca0c768 100644 --- a/backend-py/app/parsers/wb.py +++ b/backend-py/app/parsers/wb.py @@ -40,7 +40,44 @@ _HEADERS = { def search_wb(query: str, limit: int = 3, timeout: float = 12.0, max_retries: int = 2) -> list[dict[str, Any]]: - """WB через прямой JSON API. Делает экспоненциальный backoff при 429.""" + """WB через прямой JSON API. Делает экспоненциальный backoff при 429. + + Пробует несколько вариантов запроса (full → brand+model → brand only) + чтобы повысить вероятность найти товар.""" + # Генерируем варианты запросов от точного к широкому + queries = _generate_query_variants(query) + for q in queries: + results = _search_wb_one(q, limit=limit, timeout=timeout, max_retries=max_retries) + if results: + return results + return [] + + +def _generate_query_variants(query: str) -> list[str]: + """Из 'Bosch Serie 4 KGN39NW00R холодильник' делаем варианты: + 1. Bosch Serie 4 KGN39NW00R холодильник + 2. Bosch KGN39NW00R + 3. KGN39NW00R + 4. Bosch holodilnik + """ + import re + variants = [query] + parts = query.split() + # Находим модель-индекс (с цифрами и буквами) + model_idx = None + for p in parts: + if re.search(r"\d", p) and re.search(r"[a-zA-Z]", p) and len(p) >= 4: + model_idx = p + break + brand = parts[0] if parts else "" + if brand and model_idx: + variants.append(f"{brand} {model_idx}") + variants.append(model_idx) + return list(dict.fromkeys(variants)) # дедуп с сохранением порядка + + +def _search_wb_one(query: str, limit: int, timeout: float, max_retries: int) -> list[dict[str, Any]]: + """Один запрос к WB API.""" import time params = {**_DEFAULT_PARAMS, "query": query} diff --git a/miniapp/assets/podbor.css b/miniapp/assets/podbor.css index 4ce2107..5f88e83 100644 --- a/miniapp/assets/podbor.css +++ b/miniapp/assets/podbor.css @@ -1288,6 +1288,89 @@ margin-top: 2px; } +/* Блоки плюсов/минусов с маркированными списками */ +.report-pros-block, .report-cons-block { + margin-top: 8px; + padding: 8px 10px; + border-radius: 8px; + font-size: 12.5px; + line-height: 1.45; +} +.report-pros-block { + background: rgba(42, 107, 63, 0.08); + border-left: 2px solid #2A6B3F; +} +.report-cons-block { + background: rgba(138, 62, 42, 0.08); + border-left: 2px solid #8A3E2A; +} +.pc-head { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + margin-bottom: 4px; +} +.report-pros-block .pc-head { color: #2A6B3F; } +.report-cons-block .pc-head { color: #8A3E2A; } +.pc-list { + margin: 0; + padding-left: 16px; + color: var(--ink); +} +.pc-list li { margin: 2px 0; } + +.report-reasoning { + margin-top: 8px; + padding: 8px 12px; + background: var(--warm); + border-radius: 8px; + font-style: italic; + font-size: 13px; + line-height: 1.45; + color: var(--walnut); +} + +.report-cat-analysis { + font-size: 13.5px; + line-height: 1.5; + color: var(--ink-2); + font-style: italic; + padding: 10px 14px; + background: #FFFCF6; + border: 1px solid var(--line); + border-left: 3px solid var(--accent-2); + border-radius: 8px; + margin: 4px 0 4px; +} + +.report-links-head { + font-family: var(--font-mono); + font-size: 9.5px; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 4px; + width: 100%; +} +.report-links-empty { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; + color: var(--muted); + font-style: italic; + margin-top: 8px; +} + +/* Цветовая дифференциация бейджей магазинов */ +.report-link--ozon { border-color: #0044CC; color: #0044CC; } +.report-link--citilink { border-color: #FFBA00; color: #B57E00; } +.report-link--wb { border-color: #CB11AB; color: #CB11AB; } +.report-link--yamarket { border-color: #FC3F1D; color: #FC3F1D; } +.report-link--dns { border-color: #FA9300; color: #C26C00; } + .report-links { display: flex; flex-wrap: wrap; diff --git a/miniapp/assets/podbor.js b/miniapp/assets/podbor.js index 80c71d7..47cf178 100644 --- a/miniapp/assets/podbor.js +++ b/miniapp/assets/podbor.js @@ -1255,6 +1255,7 @@ const Podbor = (function () { const catLabel = catMeta?.label || catKey; const catIcon = catMeta?.icon; const models = (catData && catData.models) || []; + const catAnalysis = (catData && catData.analysis) || ""; if (!models.length) continue; const catNode = el(` @@ -1263,6 +1264,7 @@ const Podbor = (function () { ${(catIcon && ICONS[catIcon]) || ""} ${_esc(catLabel)} + ${catAnalysis ? `
${_esc(catAnalysis)}
` : ""}
`); @@ -1307,8 +1309,8 @@ const Podbor = (function () { function _renderModelCard(m) { const enriched = m.enriched || {}; - const pMin = m.price_min_rub || enriched.price_min_rub; - const pMax = m.price_max_rub || enriched.price_max_rub; + const pMin = enriched.price_min_rub || m.price_min_rub; + const pMax = enriched.price_max_rub || m.price_max_rub; const img = enriched.image_url; const rating = enriched.rating_max; const reviews = enriched.reviews_total; @@ -1324,11 +1326,17 @@ const Podbor = (function () { if (reviews) metaParts.push(`${reviews} отзыв.`); if (stores) metaParts.push(`${stores} магазинов`); - const links = []; - if (enriched.wb && enriched.wb.url) links.push({ label: "Wildberries", url: enriched.wb.url }); - if (enriched.yamarket && enriched.yamarket.url) links.push({ label: "Я.Маркет", url: enriched.yamarket.url }); - if (enriched.ozon && enriched.ozon.url) links.push({ label: "OZON", url: enriched.ozon.url }); - if (enriched.dns && enriched.dns.url) links.push({ label: "DNS", url: enriched.dns.url }); + // Бейджи источников + ссылки + const sourcesData = [ + { key: "ozon", label: "OZON", item: enriched.ozon }, + { key: "citilink", label: "Citilink", item: enriched.citilink }, + { key: "wb", label: "Wildberries", item: enriched.wb }, + { key: "yamarket", label: "Я.Маркет", item: enriched.yamarket }, + { key: "dns", label: "DNS", item: enriched.dns }, + ]; + const sourceLinks = sourcesData + .filter(s => s.item && s.item.url) + .map(s => `${s.label}${s.item.price_min_rub ? ` · ${formatRub(s.item.price_min_rub)} ₽` : ""}`); const card = el(`
@@ -1339,19 +1347,43 @@ const Podbor = (function () { ${metaParts.length ? `
${metaParts.join(" · ")}
` : ""}
${priceHtml}
${(m.highlights || []).length ? `
✓ ${m.highlights.map(_esc).join(" · ")}
` : ""} - ${(m.pros || []).length ? `
⊕ ${m.pros.slice(0, 3).map(_esc).join(" · ")}
` : ""} - ${(m.cons || []).length ? `
⊖ ${m.cons.slice(0, 2).map(_esc).join(" · ")}
` : ""} - ${links.length ? ` -
`); return card; } + function _pluralStores(n) { + const last = n % 10, lastTwo = n % 100; + if (lastTwo >= 11 && lastTwo <= 14) return "магазинов"; + if (last === 1) return "магазин"; + if (last >= 2 && last <= 4) return "магазина"; + return "магазинов"; + } + function _renderCompareTable(models) { const rows = models.map(m => { const e = m.enriched || {}; diff --git a/miniapp/index.html b/miniapp/index.html index b582758..ed41367 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,8 +12,8 @@ - - + +
@@ -21,10 +21,10 @@
- - - - - + + + + + diff --git a/miniapp/preview-report.html b/miniapp/preview-report.html index 0f15870..189c30d 100644 --- a/miniapp/preview-report.html +++ b/miniapp/preview-report.html @@ -51,24 +51,27 @@