From 22dbbed1124ddf1e896eca60519e077d7e93b559 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Sat, 16 May 2026 09:35:29 +0300 Subject: [PATCH] feat: AI contract review for clients (#/c/contract) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - main.py: _handle_contract_review() — GigaChat analyzes contract text, returns structured JSON (summary, payment, deadlines, risks, recommendations, missing_clauses) with optional targeted question support - /api/contract_review route (async, thread-pool) - CONTRACT_SYSTEM prompt: plain-language analysis in Russian, risk levels high/medium/low, missing-clause detection Frontend: - proposals.js: Proposals.mountContractReview(container) · Textarea for contract text with char counter (16k limit) · 5 preset quick-questions as tappable chips · Free-form question input · Thinking animation while AI processes · Renders structured analysis: Summary, Payment, Deadlines, Risks (color-coded), Recommendations, Missing clauses, disclaimer footer · Falls back to raw text if AI returns unstructured reply - app.js: #/c/contract route in init() and routeByHash(); «Калькулятор бюджета» replaced by «Проверить договор» in client home menu (active, not «скоро») - podbor.css: ~180 lines of contract review styles (cr-* classes, risk colors, thinking animation) - index.html: cache version → 20260516f Co-Authored-By: Claude Sonnet 4.6 --- backend-py/app/main.py | 86 +++++++++++++ miniapp/assets/app.js | 25 +++- miniapp/assets/podbor.css | 230 ++++++++++++++++++++++++++++++++++ miniapp/assets/proposals.js | 243 +++++++++++++++++++++++++++++++++++- miniapp/index.html | 26 ++-- 5 files changed, 595 insertions(+), 15 deletions(-) diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 030aa48..ac9e7cc 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -136,6 +136,7 @@ async def _dispatch_post(request: Request): "proposal_detail": proposals_mod.handle_detail, "proposal_vote": proposals_mod.handle_vote, "proposal_client_submit": proposals_mod.handle_client_submit, + "contract_review": _handle_contract_review, "ping": lambda b: {"pong": True, "time": _now_iso()}, "seed_admin": lambda b: _handle_seed_admin(), "test_ai": lambda b: _handle_test_ai(), @@ -431,6 +432,14 @@ async def api_proposal_client_submit(request: Request): return JSONResponse(proposals_mod.handle_client_submit(body)) +@app.post("/api/contract_review") +async def api_contract_review(request: Request): + """AI-анализ текста договора. GigaChat → структурированный разбор на русском.""" + body = await _safe_json(request) + import asyncio + return JSONResponse(await asyncio.to_thread(_handle_contract_review, body)) + + def _handle_daily_reminders() -> dict[str, Any]: """Находит клиентов с годовщиной договора сегодня по МСК. Дедуплицирует: один менеджер + один клиент = одно уведомление, @@ -3024,6 +3033,83 @@ def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]: return {"ok": True, "count": len(out), "measurements": out} +_CONTRACT_SYSTEM = """\ +Ты — помощник клиента мебельной фабрики ЗОВ (Россия). \ +Клиент получил договор на покупку и монтаж кухни и хочет понять его содержание. + +Твоя задача — проанализировать текст и дать ответ строго в формате JSON без Markdown-оберток. \ +Структура ответа: +{ + "summary": "2-3 предложения — о чём договор", + "payment": { + "total": "итоговая сумма если есть", + "schedule": "схема оплаты (предоплата %, доплата когда)", + "prepayment_pct": число_или_null + }, + "deadlines": [ + {"label": "Изготовление", "value": "дата или срок", "note": "подробности"} + ], + "risks": [ + {"level": "high|medium|low", "title": "заголовок", "description": "что именно"} + ], + "recommendations": ["что уточнить у менеджера или на что обратить внимание"], + "missing_clauses": ["важные пункты которых нет в тексте"] +} + +Риски level: +- high — условие явно невыгодно клиенту или может привести к потере денег +- medium — спорное, зависит от ситуации +- low — мелочь, но лучше знать + +Если есть конкретный вопрос (поле question) — добавь поле "question_answer": "ответ на вопрос". +Отвечай на русском. Будь конкретным, избегай юридического жаргона. +""" + + +def _handle_contract_review(body: dict[str, Any]) -> dict[str, Any]: + """Клиент вставляет текст договора — AI анализирует его простым языком. + body: { initData, text: str, question?: str } + """ + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth: + unsafe = body.get("initDataUnsafe") or {} + if not (isinstance(unsafe, dict) and unsafe.get("user", {}).get("id")): + return {"error": "invalid_init_data"} + + text = str(body.get("text") or "").strip() + question = str(body.get("question") or "").strip() + + if not text: + return {"error": "text_required"} + if len(text) > 16_000: + text = text[:16_000] + + user_prompt = f"Текст договора:\n\n{text}" + if question: + user_prompt += f"\n\nВопрос клиента: {question}" + + import asyncio + result = ai.call_ai( + user_prompt, + system_prompt=_CONTRACT_SYSTEM, + temperature=0.2, + max_tokens=3000, + ) + + if result.get("error"): + return {"error": result.get("text", "AI error")} + + analysis = result.get("json") or {} + return { + "ok": True, + "analysis": analysis, + "raw_text": result.get("text", ""), + "tokens": result.get("tokens", 0), + "model": result.get("model", ""), + } + + def _handle_test_ai() -> dict[str, Any]: cfg = get_config() res = ai.call_ai("Скажи одной фразой: что за фабрика ЗОВ?", diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index fc25dd9..7fa81f1 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -615,7 +615,7 @@ function renderClient(me) { items: [ { icon: "ruler", color: "blue", label: "Замер кухни", href: "#/c/measure" }, { icon: "wrench", color: "green", label: "Подобрать технику", href: "#/c/proposal" }, - { icon: "wallet", color: "gold", label: "Калькулятор бюджета", soon: true }, + { icon: "wallet", color: "gold", label: "Проверить договор", href: "#/c/contract" }, ], }, { @@ -1621,6 +1621,19 @@ async function init() { hideSplash(); return; } + if (location.hash.startsWith("#/c/contract")) { + app.innerHTML = ""; + document.body.classList.remove("has-bottom-nav"); + const oldNavC = document.getElementById("bottom-nav"); + if (oldNavC) oldNavC.remove(); + if (typeof Proposals !== "undefined") { + Proposals.mountContractReview(app); + } else { + app.innerHTML = `
Модуль не загружен
`; + } + hideSplash(); + return; + } if (me.role === "staff") { renderStaff(me); } else if (me.role === "manager") { @@ -1659,6 +1672,16 @@ function routeByHash() { } else { app.innerHTML = `
Модуль подбора не загружен
`; } + } else if (location.hash.startsWith("#/c/contract")) { + app.innerHTML = ""; + document.body.classList.remove("has-bottom-nav"); + const oldNav3 = document.getElementById("bottom-nav"); + if (oldNav3) oldNav3.remove(); + if (typeof Proposals !== "undefined") { + Proposals.mountContractReview(app); + } else { + app.innerHTML = `
Модуль не загружен
`; + } } else { // Главный экран по роли const me = window.__zovMe; diff --git a/miniapp/assets/podbor.css b/miniapp/assets/podbor.css index 0c99986..6eb9428 100644 --- a/miniapp/assets/podbor.css +++ b/miniapp/assets/podbor.css @@ -4374,3 +4374,233 @@ } .prop-section-open-link:active { opacity: 0.6; } +/* ============================================================ + AI-проверка договора (Contract Review) + ============================================================ */ + +/* Textarea + char counter */ +.cr-textarea { + font-family: var(--font-ui); + font-size: 13.5px; + line-height: 1.55; + resize: vertical; + min-height: 160px; +} +.cr-chars { + font-family: var(--font-mono); + font-size: 9.5px; + letter-spacing: 0.08em; + color: var(--muted); + margin-top: 4px; + text-align: right; +} + +/* Preset question chips */ +.cr-presets { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 0; +} + +/* Thinking animation */ +.cr-thinking { + display: flex; + align-items: center; + gap: 14px; + background: var(--warm); + border: 1px solid var(--line); + border-radius: var(--r-card); + padding: 16px 18px; + margin-top: 12px; +} +.cr-thinking-icon { + font-size: 28px; + animation: cr-rock 1.6s ease-in-out infinite; +} +@keyframes cr-rock { + 0%, 100% { transform: rotate(-8deg); } + 50% { transform: rotate(8deg); } +} +.cr-thinking-text { + font-family: var(--font-ui); + font-size: 14px; + font-weight: 500; + color: var(--ink); + line-height: 1.4; +} +.cr-thinking-sub { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; + color: var(--muted); +} + +/* Analysis output */ +.cr-analysis { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 16px; +} + +.cr-block { + background: var(--card, #FFFCF6); + border: 1px solid var(--line); + border-radius: var(--r-card); + padding: 14px 16px; +} +.cr-block-head { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 10px; +} + +/* Summary */ +.cr-summary-block { border-left: 3px solid var(--accent-2); } +.cr-summary-text { + font-family: var(--font-ui); + font-size: 14px; + line-height: 1.55; + color: var(--ink); +} + +/* Q&A block */ +.cr-qa-block { border-left: 3px solid #1A62B0; background: rgba(26,98,176,0.04); } +.cr-qa-answer { + font-family: var(--font-ui); + font-size: 14px; + line-height: 1.55; + color: var(--ink); +} + +/* Key-value pairs (payment) */ +.cr-kv-list { display: flex; flex-direction: column; gap: 8px; } +.cr-kv-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; +} +.cr-kv-label { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + flex-shrink: 0; + white-space: nowrap; +} +.cr-kv-val { + font-family: var(--font-ui); + font-size: 14px; + font-weight: 500; + color: var(--ink); + text-align: right; + flex: 1; +} + +/* Deadlines */ +.cr-deadlines { display: flex; flex-direction: column; gap: 8px; } +.cr-deadline-row { + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto; + column-gap: 12px; +} +.cr-deadline-label { + grid-column: 1; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + align-self: center; + white-space: nowrap; +} +.cr-deadline-val { + grid-column: 2; + font-family: var(--font-ui); + font-size: 14px; + font-weight: 600; + color: var(--ink); +} +.cr-deadline-note { + grid-column: 2; + font-size: 12px; + color: var(--muted); + line-height: 1.35; + margin-top: 1px; +} + +/* Risks */ +.cr-risks { display: flex; flex-direction: column; gap: 10px; } +.cr-risk { + padding: 10px 12px; + border-radius: 8px; + border-left: 3px solid; +} +.cr-risk-high { background: rgba(192,57,43,0.07); border-color: #C0392B; } +.cr-risk-medium { background: rgba(230,126,34,0.07); border-color: #E67E22; } +.cr-risk-low { background: rgba(39,174,96,0.06); border-color: #27AE60; } +.cr-risk-head { + font-family: var(--font-ui); + font-size: 13.5px; + font-weight: 600; + color: var(--ink); + line-height: 1.25; + margin-bottom: 4px; +} +.cr-risk-desc { + font-size: 12.5px; + line-height: 1.45; + color: var(--ink-2); +} + +/* Recommendations & missing */ +.cr-rec-list { + margin: 0; + padding-left: 18px; + display: flex; + flex-direction: column; + gap: 5px; +} +.cr-rec-list li { + font-size: 13.5px; + line-height: 1.45; + color: var(--ink); +} +.cr-missing-block { border-left: 3px solid var(--muted); background: var(--paper-2); } +.cr-missing-list li { color: var(--muted); } + +/* Footer note */ +.cr-footer-note { + font-family: var(--font-mono); + font-size: 9.5px; + letter-spacing: 0.06em; + color: var(--muted); + line-height: 1.5; + text-align: center; + padding: 4px 0 8px; +} + +/* Raw fallback */ +.cr-raw { + white-space: pre-wrap; + font-family: var(--font-ui); + font-size: 13.5px; + line-height: 1.55; + color: var(--ink); + background: var(--paper-2); + border: 1px solid var(--line); + border-radius: var(--r-card); + padding: 14px 16px; + margin-top: 12px; +} + diff --git a/miniapp/assets/proposals.js b/miniapp/assets/proposals.js index 11c1c7f..f2b3709 100644 --- a/miniapp/assets/proposals.js +++ b/miniapp/assets/proposals.js @@ -810,10 +810,251 @@ const Proposals = (function () { return wrap; } + // ══════════════════════════════════════════════════════════ + // CONTRACT REVIEW (AI-анализ договора для клиента) + // ══════════════════════════════════════════════════════════ + + // Preset questions that appear as quick-tap chips + const CONTRACT_PRESETS = [ + "Какие условия оплаты?", + "Когда доставка и монтаж?", + "Что будет если я откажусь?", + "Есть ли штрафы?", + "На что обратить внимание?", + ]; + + async function mountContractReview(container) { + container.innerHTML = ""; + + container.appendChild(el(` +
+ +
ПРОВЕРКА ДОГОВОРА
+
+
+ `)); + container.querySelector(".podbor-back").addEventListener("click", () => history.back()); + + container.appendChild(el(` +
+

Проверим
ваш договор

+

Вставьте текст договора — AI объяснит условия простым языком, найдёт риски и подскажет, что уточнить.

+ +
+
Текст договора *
+ +
0 / 16 000 символов
+
+ +
+
Конкретный вопрос (необязательно)
+
+ ${CONTRACT_PRESETS.map(q => + `` + ).join("")} +
+ +
+ +
+ +
+
+
+ `)); + + // Char counter + const textarea = container.querySelector("#cr_text"); + const charEl = container.querySelector("#cr_chars"); + textarea.addEventListener("input", () => { + const n = textarea.value.length; + charEl.textContent = `${n.toLocaleString("ru-RU")} / 16 000 символов`; + charEl.style.color = n > 14000 ? "#C0392B" : "var(--muted)"; + }); + + // Preset chips → fill question input + container.querySelectorAll(".cr-preset").forEach(btn => { + btn.addEventListener("click", () => { + container.querySelector("#cr_question").value = btn.textContent.trim(); + container.querySelectorAll(".cr-preset").forEach(b => b.classList.remove("on")); + btn.classList.add("on"); + haptic && haptic("selection"); + }); + }); + + // Submit + container.querySelector("#cr_submit").addEventListener("click", async () => { + const btn = container.querySelector("#cr_submit"); + const result = container.querySelector("#cr_result"); + const text = textarea.value.trim(); + const question = (container.querySelector("#cr_question")?.value || "").trim(); + + if (!text) { + result.innerHTML = `
Вставьте текст договора
`; + return; + } + + btn.disabled = true; + btn.innerHTML = `Анализируем…`; + result.innerHTML = ` +
+
🤔
+
AI читает договор…
Обычно занимает 10–25 секунд
+
`; + + try { + const data = await apiFetch("contract_review", { text, question }); + if (data.error) { + result.innerHTML = `
Ошибка: ${escHtml(data.error)}
`; + btn.disabled = false; btn.textContent = "🤖 Анализировать"; + return; + } + haptic && haptic("success"); + result.innerHTML = ""; + result.appendChild(renderContractAnalysis(data.analysis, data.raw_text, question)); + btn.disabled = false; btn.textContent = "🤖 Анализировать снова"; + // Scroll to result + result.scrollIntoView({ behavior: "smooth", block: "start" }); + } catch (e) { + result.innerHTML = `
Сеть: ${escHtml(e.message)}
`; + btn.disabled = false; btn.textContent = "🤖 Анализировать"; + } + }); + } + + function renderContractAnalysis(analysis, rawText, question) { + // If AI returned unstructured text (not JSON), show it plain + if (!analysis || !Object.keys(analysis).length) { + return el(`
${escHtml(rawText || "AI не вернул анализ")}
`); + } + + const wrap = el(`
`); + + // Summary + if (analysis.summary) { + wrap.appendChild(el(` +
+
📋 Резюме
+
${escHtml(analysis.summary)}
+
+ `)); + } + + // Question answer (if specific question asked) + if (question && analysis.question_answer) { + wrap.appendChild(el(` +
+
💬 ${escHtml(question)}
+
${escHtml(analysis.question_answer)}
+
+ `)); + } + + // Payment + const pay = analysis.payment; + if (pay && (pay.total || pay.schedule)) { + const rows = []; + if (pay.total) rows.push(["Итого", pay.total]); + if (pay.schedule) rows.push(["Схема оплаты", pay.schedule]); + if (pay.prepayment_pct != null) rows.push(["Предоплата", `${pay.prepayment_pct}%`]); + wrap.appendChild(el(` +
+
💰 Оплата
+
+ ${rows.map(([k, v]) => ` +
+ ${escHtml(k)} + ${escHtml(String(v))} +
`).join("")} +
+
+ `)); + } + + // Deadlines + const deadlines = analysis.deadlines || []; + if (deadlines.length) { + const rows = deadlines.map(d => ` +
+ ${escHtml(d.label || "")} + ${escHtml(d.value || "—")} + ${d.note ? `${escHtml(d.note)}` : ""} +
`).join(""); + wrap.appendChild(el(` +
+
⏰ Сроки
+
${rows}
+
+ `)); + } + + // Risks + const risks = analysis.risks || []; + if (risks.length) { + const riskItems = risks.map(r => { + const cls = r.level === "high" ? "high" : r.level === "medium" ? "medium" : "low"; + const icon = r.level === "high" ? "🔴" : r.level === "medium" ? "🟡" : "🟢"; + return ` +
+
${icon} ${escHtml(r.title || "")}
+
${escHtml(r.description || "")}
+
`; + }).join(""); + wrap.appendChild(el(` +
+
⚠️ Риски
+
${riskItems}
+
+ `)); + } + + // Recommendations + const recs = analysis.recommendations || []; + if (recs.length) { + wrap.appendChild(el(` +
+
✅ Рекомендации
+
    + ${recs.map(r => `
  • ${escHtml(r)}
  • `).join("")} +
+
+ `)); + } + + // Missing clauses + const missing = analysis.missing_clauses || []; + if (missing.length) { + wrap.appendChild(el(` +
+
❓ Чего нет в договоре
+
    + ${missing.map(m => `
  • ${escHtml(m)}
  • `).join("")} +
+
+ `)); + } + + // Footer note + wrap.appendChild(el(` + + `)); + + return wrap; + } + // ══════════════════════════════════════════════════════════ // PUBLIC API // ══════════════════════════════════════════════════════════ - return { mountClient, mountManager }; + return { mountClient, mountManager, mountContractReview }; })(); diff --git a/miniapp/index.html b/miniapp/index.html index 57f908e..bc8a687 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,8 +12,8 @@ - - + + @@ -35,16 +35,16 @@
CRM
- - - - - - - - - - - + + + + + + + + + + +