zov-tech/miniapp/preview-report.html
wasrusgen ca342c0641 ai+report: deeper analysis — required pros/cons, category insights, source visibility
AI PROMPT (ai.py):
- Requires minimum 3 pros + 2 cons per model with NUMBERS (36 dB, 463 L, A++, не 'тихий/большой')
- New field 'reasoning' — 1-sentence why-this-model justification
- New per-category 'analysis' — 2-3 sentences about trade-offs
- Strict rules: no fake article numbers, account for parallel-import price markup
- Russian market 2026 awareness: Haier/Korting up, Bosch/Siemens ⚠

TELEGRAM FORMAT (main.py):
- Renders category analysis as italic prelude
- Lists pros/cons as bullet lists (up to 4 pros, 3 cons)
- Shows '🛒 Нашли в: OZON · Citilink · WB' line listing successful sources
- Rating + reviews + stores count line: '📊 ★ 4.7 · 1242 отзыв. · 12 магаз.'
- Direct link to best store: '🔗 Открыть в магазине'

WB PARSER:
- Generates 3 query variants per request: full → brand+model → model only
- Increases hit rate when AI search_query is too verbose
- First non-empty variant wins

MINIAPP REPORT (podbor.js + podbor.css):
- Category analysis block above models (italic, walnut left-border)
- Pros block: green tinted bg, bullet list, header 'Плюсы'
- Cons block: terracotta tinted bg, bullet list, header 'Минусы'
- Reasoning chip: 💡 italic in warm background
- Source badges with per-store price '<store> · 89 990 ₽'
- Color-coded source links: OZON blue, Citilink yellow, WB pink, Я.Маркет red, DNS orange
- 'X магазинов нашли товар' header + plural fix
- '— не найден' fallback if 0 sources

PREVIEW (preview-report.html):
- Mock updated with Haier as flagship (more relevant for 2026 RF)
- Shows analysis, reasoning, source spread (4 stores with different prices)
2026-05-11 14:34:08 +03:00

343 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#FBF7F0">
<title>Превью отчёта · ЗОВ</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap">
<link rel="stylesheet" href="assets/styles.css?v=20260511e">
<link rel="stylesheet" href="assets/podbor.css?v=20260511e">
<style>
body { padding: 24px 16px; background: var(--paper); }
.wrap { max-width: 720px; margin: 0 auto; }
.preview-kicker {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 6px;
}
.preview-title {
font-family: var(--font-display);
font-style: italic;
font-size: 32px;
line-height: 1.05;
letter-spacing: -0.02em;
margin: 0 0 8px;
color: var(--ink);
}
.preview-lede {
color: var(--muted);
font-size: 14px;
margin: 0 0 24px;
}
</style>
</head>
<body>
<div class="wrap">
<div class="preview-kicker">Preview · Inline Report v1</div>
<h1 class="preview-title">Отчёт<br><em style="color: var(--accent-2)">после подбора</em></h1>
<p class="preview-lede">Как будет выглядеть страница для менеджера и клиента после нажатия «Отправить». Mock-данные.</p>
<div id="reportRoot"></div>
</div>
<script src="assets/icons.js?v=20260511e"></script>
<script src="assets/podbor.config.js?v=20260511e"></script>
<script>
/* Mock-данные — имитируем AI-ответ с DNS+WB+Я.Маркет обогащением */
const MOCK_AI = {
summary: "Подобран комплект Haier для семьи из 3 человек, бюджет средний. Холодильник с NoFrost + индукционная варочная Haier и компактная духовка с конвекцией. Все три прибора одной марки для дизайн-единства и тише в эксплуатации.",
by_category: {
fridge: {
analysis: "В этом бюджете 2026 года Haier и Korting вытеснили Bosch по соотношению цена/качество. Haier C4F744CMG — оптимальный двухкамерный по тишине и объёму, Liebherr — премиум-вариант с зоной свежести, Бирюса — бюджетная страховка.",
models: [
{
brand: "Haier", model: "C4F744CMG",
price_min_rub: 89990, price_max_rub: 105000,
highlights: ["Total NoFrost (не нужно размораживать)", "Инвертор (тише и -30% энергии)", "Класс A++ (~30% экономии)"],
pros: ["тихий 36 дБ — на 4 дБ тише среднего по сегменту", "большой объём 463 л против 380 л у Bosch в этой цене", "инвертор + класс A++ — экономия ~30% против A+ моделей", "10 лет гарантии на компрессор"],
cons: ["глубина 660 мм — на 60 мм больше стандартной ниши, проверить на замере", "нет зоны свежести BioFresh — в этом плане Liebherr заметно лучше"],
reasoning: "Лучший по цена/качество в среднем сегменте. Тише и больше Bosch в той же цене, но без премиум-зоны свежести.",
tier: "middle",
enriched: {
image_url: "https://placehold.co/200x200/F5EDDC/6B4A2B?text=Haier",
price_min_rub: 89990, price_max_rub: 105000,
rating_max: 4.7, reviews_total: 1242, stores_count: 12,
citilink: { url: "https://www.citilink.ru/product/haier-123/", price_min_rub: 92000 },
ozon: { url: "https://www.ozon.ru/product/haier-c4f744cmg/", price_min_rub: 89990 },
wb: { url: "https://www.wildberries.ru/catalog/123/detail.aspx", price_min_rub: 94500 },
yamarket: { url: "https://market.yandex.ru/product--haier-c4f744cmg/123", price_min_rub: 105000 }
}
},
{
brand: "Liebherr", model: "CNd 5223",
price_min_rub: 109900, price_max_rub: 124000,
highlights: ["BioFresh (зона свежести)", "NoFrost", "SoftClose"],
pros: ["премиум-качество сборки", "очень тихий 34дБ", "10 лет гарантии на компрессор"],
cons: ["цена выше среднего на +15%"],
tier: "premium",
enriched: {
image_url: "https://placehold.co/200x200/EFE3CC/6B4A2B?text=Liebherr",
price_min_rub: 109900, price_max_rub: 124000,
rating_max: 4.9, reviews_total: 873, stores_count: 8,
wb: { url: "#" }, yamarket: { url: "#" }
}
},
{
brand: "Indesit", model: "ITS 4180 W",
price_min_rub: 39990,
highlights: ["LowFrost (размораживать только мороз)", "класс A+"],
pros: ["доступная цена", "компактные габариты"],
cons: ["без инвертора", "шум 42дБ"],
tier: "budget",
enriched: {
image_url: "https://placehold.co/200x200/FBF7F0/6B4A2B?text=Indesit",
price_min_rub: 39990, price_max_rub: 44900,
rating_max: 4.3, reviews_total: 2100, stores_count: 15,
dns: { url: "#" }, ozon: { url: "#" }
}
}
]
},
hob: {
models: [
{
brand: "Bosch", model: "PUE611BB5E",
price_min_rub: 59900, price_max_rub: 68000,
highlights: ["Индукция", "PowerBoost (форсаж — кипятит за минуту)", "TouchSelect (плавная регулировка)"],
pros: ["4 индукционные зоны", "безопасность для детей", "класс A"],
cons: ["требует индукционной посуды"],
tier: "middle",
enriched: {
image_url: "https://placehold.co/200x200/F5EDDC/6B4A2B?text=Bosch+PUE",
rating_max: 4.6, reviews_total: 845, stores_count: 10,
wb: { url: "#" }, yamarket: { url: "#" }
}
},
{
brand: "Siemens", model: "EH675FJC1E",
price_min_rub: 79900, price_max_rub: 89000,
highlights: ["Индукция", "FlexZone (объединение зон)", "Hob2Hood (управление вытяжкой)"],
pros: ["варочная управляет вытяжкой", "5 зон + Flex", "8-летняя гарантия"],
cons: ["цена премиум-сегмента"],
tier: "premium",
enriched: {
image_url: "https://placehold.co/200x200/EFE3CC/6B4A2B?text=Siemens",
rating_max: 4.8, reviews_total: 432, stores_count: 6,
yamarket: { url: "#" }, ozon: { url: "#" }
}
}
]
}
},
total_price_estimate_rub: { min: 280000, max: 420000 },
budget_status: "в_рамках",
warnings: [
"Холодильник Bosch KGN39 имеет глубину 660мм — уточнить нишу клиента (стандарт 600мм)"
]
};
/* ---- mini-копия renderReport из podbor.js, чтобы превью работало автономно ---- */
function el(html) {
const t = document.createElement("template");
t.innerHTML = html.trim();
return t.content.firstChild;
}
function _esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function formatRub(n) {
if (!n) return "—";
return Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
}
function renderReport(ai, leadId) {
const summary = ai.summary || "";
const byCat = ai.by_category || {};
const total = ai.total_price_estimate_rub || {};
const budgetStatus = ai.budget_status || "";
const warnings = ai.warnings || [];
const wrap = el(`<section class="report"></section>`);
wrap.appendChild(el(`
<div class="report-head">
<div class="kicker">Отчёт · ${leadId.slice(0, 8)}</div>
${summary ? `<p class="report-summary">${_esc(summary)}</p>` : ""}
</div>
`));
for (const [catKey, catData] of Object.entries(byCat)) {
const catMeta = (window.PODBOR_CATEGORIES || []).find(c => c.key === catKey);
const catLabel = catMeta?.label || catKey;
const catIcon = catMeta?.icon;
const models = (catData && catData.models) || [];
if (!models.length) continue;
const catAnalysis = (catData && catData.analysis) || "";
const catNode = el(`
<div class="report-cat">
<h3 class="report-cat-head">
<span class="report-cat-icon">${(catIcon && window.ICONS && window.ICONS[catIcon]) || ""}</span>
${_esc(catLabel)}
</h3>
${catAnalysis ? `<div class="report-cat-analysis">${_esc(catAnalysis)}</div>` : ""}
<div class="report-models"></div>
</div>
`);
const modelsWrap = catNode.querySelector(".report-models");
for (const m of models) {
modelsWrap.appendChild(_renderModelCard(m));
}
if (models.length >= 2) {
modelsWrap.appendChild(_renderCompareTable(models));
}
wrap.appendChild(catNode);
}
if (total && (total.min || total.max)) {
const tmin = total.min, tmax = total.max;
const range = (tmin && tmax && tmin !== tmax)
? `${formatRub(tmin)}${formatRub(tmax)}`
: `${formatRub(tmin || tmax)}`;
wrap.appendChild(el(`
<div class="report-total">
<span class="lbl">ИТОГО</span>
<strong>${range}</strong>
${budgetStatus ? `<span class="status">${_esc(budgetStatus)}</span>` : ""}
</div>
`));
}
if (warnings.length) {
const wn = el(`<div class="report-warnings"></div>`);
warnings.forEach(w => wn.appendChild(el(`<div>⚠️ ${_esc(w)}</div>`)));
wrap.appendChild(wn);
}
return wrap;
}
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 img = enriched.image_url;
const rating = enriched.rating_max;
const reviews = enriched.reviews_total;
const stores = enriched.stores_count;
const priceHtml = (pMin && pMax && pMin !== pMax)
? `<strong>${formatRub(pMin)}</strong> — <strong>${formatRub(pMax)}</strong> ₽`
: pMin ? `<strong>${formatRub(pMin)}</strong> ₽`
: `<span class="muted">цена уточняется</span>`;
const metaParts = [];
if (rating) metaParts.push(`<span class="rating">★ ${Number(rating).toFixed(1)}</span>`);
if (reviews) metaParts.push(`<span class="reviews">${reviews} отзыв.</span>`);
if (stores) metaParts.push(`<span class="stores">${stores} магазинов</span>`);
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 => `<a href="${_esc(s.item.url)}" target="_blank" rel="noopener noreferrer" class="report-link report-link--${s.key}">${s.label}${s.item.price_min_rub ? ` · ${formatRub(s.item.price_min_rub)}` : ""}</a>`);
function _plural(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 "магазинов";
}
return el(`
<article class="report-model">
<div class="report-model-img${img ? "" : " placeholder"}">${img ? `<img src="${_esc(img)}" alt="" loading="lazy">` : ""}</div>
<div class="report-model-body">
<div class="report-model-brand">${_esc(m.brand || "")}</div>
<div class="report-model-name">${_esc(m.model || "")}</div>
${metaParts.length ? `<div class="report-model-meta">${metaParts.join(" · ")}</div>` : ""}
<div class="report-model-price">${priceHtml}</div>
${(m.highlights || []).length ? `<div class="report-highlights">✓ ${m.highlights.map(_esc).join(" · ")}</div>` : ""}
${(m.pros || []).length ? `
<div class="report-pros-block">
<div class="pc-head">Плюсы</div>
<ul class="pc-list">${m.pros.slice(0, 4).map(p => `<li>${_esc(p)}</li>`).join("")}</ul>
</div>
` : ""}
${(m.cons || []).length ? `
<div class="report-cons-block">
<div class="pc-head">Минусы</div>
<ul class="pc-list">${m.cons.slice(0, 3).map(c => `<li>${_esc(c)}</li>`).join("")}</ul>
</div>
` : ""}
${m.reasoning ? `<div class="report-reasoning">💡 ${_esc(m.reasoning)}</div>` : ""}
${sourceLinks.length ? `
<div class="report-links">
<div class="report-links-head">${sourceLinks.length} ${_plural(sourceLinks.length)} нашли товар:</div>
${sourceLinks.join("")}
</div>
` : `<div class="report-links-empty">— не найден в подключённых магазинах</div>`}
</div>
</article>
`);
}
function _renderCompareTable(models) {
const rows = models.map(m => {
const e = m.enriched || {};
const p = m.price_min_rub || e.price_min_rub;
return `
<tr>
<td><strong>${_esc(m.brand || "")}</strong> ${_esc(m.model || "")}</td>
<td>${p ? formatRub(p) + " ₽" : "—"}</td>
<td>${e.reviews_total || "—"}</td>
<td>${e.rating_max ? Number(e.rating_max).toFixed(1) : "—"}</td>
</tr>
`;
}).join("");
return el(`
<details class="report-compare" open>
<summary>Сравнить модели</summary>
<table>
<thead>
<tr><th>Модель</th><th>Цена от</th><th>Отзывов</th><th>★</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
</details>
`);
}
document.getElementById("reportRoot").appendChild(renderReport(MOCK_AI, "ABC12345"));
</script>
</body>
</html>