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:
wasrusgen 2026-05-16 09:35:29 +03:00
parent 4abd7b2ecd
commit 22dbbed112
5 changed files with 595 additions and 15 deletions

View File

@ -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("Скажи одной фразой: что за фабрика ЗОВ?",

View File

@ -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;

View File

@ -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;
}

View File

@ -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="Вставьте сюда текст договора или его ключевые разделы…&#10;&#10;Например: условия оплаты, сроки, ответственность сторон, гарантия."
></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">Обычно занимает 1025 секунд</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 };
})(); })();

View File

@ -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>