mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 16:24:50 +00:00
feat: AI contract review for clients (#/c/contract)
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 <noreply@anthropic.com>
This commit is contained in:
parent
4abd7b2ecd
commit
22dbbed112
@ -136,6 +136,7 @@ async def _dispatch_post(request: Request):
|
|||||||
"proposal_detail": proposals_mod.handle_detail,
|
"proposal_detail": proposals_mod.handle_detail,
|
||||||
"proposal_vote": proposals_mod.handle_vote,
|
"proposal_vote": proposals_mod.handle_vote,
|
||||||
"proposal_client_submit": proposals_mod.handle_client_submit,
|
"proposal_client_submit": proposals_mod.handle_client_submit,
|
||||||
|
"contract_review": _handle_contract_review,
|
||||||
"ping": lambda b: {"pong": True, "time": _now_iso()},
|
"ping": lambda b: {"pong": True, "time": _now_iso()},
|
||||||
"seed_admin": lambda b: _handle_seed_admin(),
|
"seed_admin": lambda b: _handle_seed_admin(),
|
||||||
"test_ai": lambda b: _handle_test_ai(),
|
"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))
|
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]:
|
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}
|
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]:
|
def _handle_test_ai() -> dict[str, Any]:
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
res = ai.call_ai("Скажи одной фразой: что за фабрика ЗОВ?",
|
res = ai.call_ai("Скажи одной фразой: что за фабрика ЗОВ?",
|
||||||
|
|||||||
@ -615,7 +615,7 @@ function renderClient(me) {
|
|||||||
items: [
|
items: [
|
||||||
{ icon: "ruler", color: "blue", label: "Замер кухни", href: "#/c/measure" },
|
{ icon: "ruler", color: "blue", label: "Замер кухни", href: "#/c/measure" },
|
||||||
{ icon: "wrench", color: "green", label: "Подобрать технику", href: "#/c/proposal" },
|
{ 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();
|
hideSplash();
|
||||||
return;
|
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 = `<div class="error">Модуль не загружен</div>`;
|
||||||
|
}
|
||||||
|
hideSplash();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (me.role === "staff") {
|
if (me.role === "staff") {
|
||||||
renderStaff(me);
|
renderStaff(me);
|
||||||
} else if (me.role === "manager") {
|
} else if (me.role === "manager") {
|
||||||
@ -1659,6 +1672,16 @@ function routeByHash() {
|
|||||||
} else {
|
} else {
|
||||||
app.innerHTML = `<div class="error">Модуль подбора не загружен</div>`;
|
app.innerHTML = `<div class="error">Модуль подбора не загружен</div>`;
|
||||||
}
|
}
|
||||||
|
} 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 = `<div class="error">Модуль не загружен</div>`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Главный экран по роли
|
// Главный экран по роли
|
||||||
const me = window.__zovMe;
|
const me = window.__zovMe;
|
||||||
|
|||||||
@ -4374,3 +4374,233 @@
|
|||||||
}
|
}
|
||||||
.prop-section-open-link:active { opacity: 0.6; }
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -810,10 +810,251 @@ const Proposals = (function () {
|
|||||||
return wrap;
|
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(`
|
||||||
|
<header class="podbor-header">
|
||||||
|
<button class="podbor-back" aria-label="Назад">${(typeof ICONS !== "undefined" && ICONS.arrow_left) || "‹"}</button>
|
||||||
|
<div class="podbor-title">ПРОВЕРКА ДОГОВОРА</div>
|
||||||
|
<div style="width:28px"></div>
|
||||||
|
</header>
|
||||||
|
`));
|
||||||
|
container.querySelector(".podbor-back").addEventListener("click", () => history.back());
|
||||||
|
|
||||||
|
container.appendChild(el(`
|
||||||
|
<section class="podbor-step">
|
||||||
|
<h2 class="display-title">Проверим<br><span class="accent">ваш договор</span></h2>
|
||||||
|
<p class="lede">Вставьте текст договора — AI объяснит условия простым языком, найдёт риски и подскажет, что уточнить.</p>
|
||||||
|
|
||||||
|
<div class="prop-field-group">
|
||||||
|
<div class="prop-field-label">Текст договора *</div>
|
||||||
|
<textarea id="cr_text" class="prop-input cr-textarea"
|
||||||
|
rows="8"
|
||||||
|
placeholder="Вставьте сюда текст договора или его ключевые разделы… Например: условия оплаты, сроки, ответственность сторон, гарантия."
|
||||||
|
></textarea>
|
||||||
|
<div class="cr-chars" id="cr_chars">0 / 16 000 символов</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prop-field-group">
|
||||||
|
<div class="prop-field-label">Конкретный вопрос (необязательно)</div>
|
||||||
|
<div class="cr-presets" id="cr_presets">
|
||||||
|
${CONTRACT_PRESETS.map(q =>
|
||||||
|
`<button class="prop-chip cr-preset" type="button">${escHtml(q)}</button>`
|
||||||
|
).join("")}
|
||||||
|
</div>
|
||||||
|
<input type="text" id="cr_question" class="prop-input" style="margin-top:8px;"
|
||||||
|
placeholder="Или напишите свой вопрос…">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="podbor-cta-row" style="margin-top:20px;">
|
||||||
|
<button class="btn-primary" id="cr_submit" type="button">
|
||||||
|
🤖 Анализировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="cr_result" class="submit-result"></div>
|
||||||
|
</section>
|
||||||
|
`));
|
||||||
|
|
||||||
|
// 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 = `<div class="error">Вставьте текст договора</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = `<span class="spinner-inline"></span>Анализируем…`;
|
||||||
|
result.innerHTML = `
|
||||||
|
<div class="cr-thinking">
|
||||||
|
<div class="cr-thinking-icon">🤔</div>
|
||||||
|
<div class="cr-thinking-text">AI читает договор…<br><span class="cr-thinking-sub">Обычно занимает 10–25 секунд</span></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiFetch("contract_review", { text, question });
|
||||||
|
if (data.error) {
|
||||||
|
result.innerHTML = `<div class="error">Ошибка: ${escHtml(data.error)}</div>`;
|
||||||
|
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 = `<div class="error">Сеть: ${escHtml(e.message)}</div>`;
|
||||||
|
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(`<div class="cr-raw">${escHtml(rawText || "AI не вернул анализ")}</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrap = el(`<div class="cr-analysis"></div>`);
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
if (analysis.summary) {
|
||||||
|
wrap.appendChild(el(`
|
||||||
|
<div class="cr-block cr-summary-block">
|
||||||
|
<div class="cr-block-head">📋 Резюме</div>
|
||||||
|
<div class="cr-summary-text">${escHtml(analysis.summary)}</div>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Question answer (if specific question asked)
|
||||||
|
if (question && analysis.question_answer) {
|
||||||
|
wrap.appendChild(el(`
|
||||||
|
<div class="cr-block cr-qa-block">
|
||||||
|
<div class="cr-block-head">💬 ${escHtml(question)}</div>
|
||||||
|
<div class="cr-qa-answer">${escHtml(analysis.question_answer)}</div>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(`
|
||||||
|
<div class="cr-block">
|
||||||
|
<div class="cr-block-head">💰 Оплата</div>
|
||||||
|
<div class="cr-kv-list">
|
||||||
|
${rows.map(([k, v]) => `
|
||||||
|
<div class="cr-kv-row">
|
||||||
|
<span class="cr-kv-label">${escHtml(k)}</span>
|
||||||
|
<span class="cr-kv-val">${escHtml(String(v))}</span>
|
||||||
|
</div>`).join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deadlines
|
||||||
|
const deadlines = analysis.deadlines || [];
|
||||||
|
if (deadlines.length) {
|
||||||
|
const rows = deadlines.map(d => `
|
||||||
|
<div class="cr-deadline-row">
|
||||||
|
<span class="cr-deadline-label">${escHtml(d.label || "")}</span>
|
||||||
|
<span class="cr-deadline-val">${escHtml(d.value || "—")}</span>
|
||||||
|
${d.note ? `<span class="cr-deadline-note">${escHtml(d.note)}</span>` : ""}
|
||||||
|
</div>`).join("");
|
||||||
|
wrap.appendChild(el(`
|
||||||
|
<div class="cr-block">
|
||||||
|
<div class="cr-block-head">⏰ Сроки</div>
|
||||||
|
<div class="cr-deadlines">${rows}</div>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 `
|
||||||
|
<div class="cr-risk cr-risk-${cls}">
|
||||||
|
<div class="cr-risk-head">${icon} ${escHtml(r.title || "")}</div>
|
||||||
|
<div class="cr-risk-desc">${escHtml(r.description || "")}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
wrap.appendChild(el(`
|
||||||
|
<div class="cr-block">
|
||||||
|
<div class="cr-block-head">⚠️ Риски</div>
|
||||||
|
<div class="cr-risks">${riskItems}</div>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recommendations
|
||||||
|
const recs = analysis.recommendations || [];
|
||||||
|
if (recs.length) {
|
||||||
|
wrap.appendChild(el(`
|
||||||
|
<div class="cr-block">
|
||||||
|
<div class="cr-block-head">✅ Рекомендации</div>
|
||||||
|
<ul class="cr-rec-list">
|
||||||
|
${recs.map(r => `<li>${escHtml(r)}</li>`).join("")}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing clauses
|
||||||
|
const missing = analysis.missing_clauses || [];
|
||||||
|
if (missing.length) {
|
||||||
|
wrap.appendChild(el(`
|
||||||
|
<div class="cr-block cr-missing-block">
|
||||||
|
<div class="cr-block-head">❓ Чего нет в договоре</div>
|
||||||
|
<ul class="cr-rec-list cr-missing-list">
|
||||||
|
${missing.map(m => `<li>${escHtml(m)}</li>`).join("")}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer note
|
||||||
|
wrap.appendChild(el(`
|
||||||
|
<div class="cr-footer-note">
|
||||||
|
⚠ Это автоматический анализ — не юридическая консультация. \
|
||||||
|
Уточняйте спорные пункты у менеджера или юриста.
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════
|
||||||
// PUBLIC API
|
// PUBLIC API
|
||||||
// ══════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
return { mountClient, mountManager };
|
return { mountClient, mountManager, mountContractReview };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -12,8 +12,8 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap">
|
||||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
<link rel="stylesheet" href="assets/styles.css?v=20260516e">
|
<link rel="stylesheet" href="assets/styles.css?v=20260516f">
|
||||||
<link rel="stylesheet" href="assets/podbor.css?v=20260516e">
|
<link rel="stylesheet" href="assets/podbor.css?v=20260516f">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
|
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
|
||||||
@ -35,16 +35,16 @@
|
|||||||
<div class="brand-tagline-gold">CRM</div>
|
<div class="brand-tagline-gold">CRM</div>
|
||||||
</div>
|
</div>
|
||||||
<main id="app"></main>
|
<main id="app"></main>
|
||||||
<script src="assets/icons.js?v=20260516e"></script>
|
<script src="assets/icons.js?v=20260516f"></script>
|
||||||
<script src="assets/podbor.config.js?v=20260516e"></script>
|
<script src="assets/podbor.config.js?v=20260516f"></script>
|
||||||
<script src="assets/podbor.picts.js?v=20260516e"></script>
|
<script src="assets/podbor.picts.js?v=20260516f"></script>
|
||||||
<script src="assets/podbor.js?v=20260516e"></script>
|
<script src="assets/podbor.js?v=20260516f"></script>
|
||||||
<script src="assets/clients.js?v=20260516e"></script>
|
<script src="assets/clients.js?v=20260516f"></script>
|
||||||
<script src="assets/zamer-picts.js?v=20260516e"></script>
|
<script src="assets/zamer-picts.js?v=20260516f"></script>
|
||||||
<script src="assets/measurements.js?v=20260516e"></script>
|
<script src="assets/measurements.js?v=20260516f"></script>
|
||||||
<script src="assets/request.js?v=20260516e"></script>
|
<script src="assets/request.js?v=20260516f"></script>
|
||||||
<script src="assets/assembly.js?v=20260516e"></script>
|
<script src="assets/assembly.js?v=20260516f"></script>
|
||||||
<script src="assets/proposals.js?v=20260516e"></script>
|
<script src="assets/proposals.js?v=20260516f"></script>
|
||||||
<script src="assets/app.js?v=20260516e"></script>
|
<script src="assets/app.js?v=20260516f"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user