v1.7.0: refactor max_bot to flat structure, add VCF+UserModel+NLP history context, portal pages and proxy fixes

- Refactored max_bot from nested packages to flat module structure
- Q2: Extended BotUser model (patronymic, email, org, address, vcf_raw, contact_hash, phone_verified, email_verified, last_interaction, total_conversations, total_tickets)
- Q2: VCF parser (FN, N, TEL, EMAIL, ORG, ADR), upsert on re-contact, NLP history context (_get_user_history_context -> YandexGPT)
- Q1: Broadcast preview modal with 10s confirmation timer
- Q3: CSS var(--white)->var(--bg-card), var(--text)->var(--text-primary)
- Q4: bot_settings showNotification(), editable max_bot_id
- Q5: Webhook secret passthrough via X-Max-Bot-Api-Secret
- Masking sensitive keys, dialog_cleared handler, migrate via _add_column_if_not_exists()
- Rate limit (asyncio.sleep 0.5 per 10), dead code removed, conv.intent context in contact.py
- Portal pages: bot_consent, bot_kb (edit), bot_settings, bot_test, bot_tickets, portal_settings
- Tests: 21/21 passing, added test_yandex_gpt.py, test_email_sender.py
- Deploy: deploy_full.sh, schema.sql, seed_knowledge_base.sql
This commit is contained in:
2026-05-29 02:30:30 +03:00
parent 493e0b37a1
commit 72b6879f4b
234 changed files with 26768 additions and 6240 deletions
+239
View File
@@ -0,0 +1,239 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.kb-table { width:100%; border-collapse:collapse; }
.kb-table th, .kb-table td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:13px; }
.kb-table th { font-weight:600; color:var(--text-muted); background:var(--light-bg); }
.kb-table td { vertical-align:top; }
.kb-table .active-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
.kb-table .active-badge.yes { background:#d4edda; color:#155724; }
.kb-table .active-badge.no { background:#f8d7da; color:#721c24; }
.kb-modal { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.4);
z-index:1000; align-items:center; justify-content:center; }
.kb-modal.open { display:flex; }
.kb-modal-content { background:var(--bg-card); border-radius:12px; padding:30px; width:600px; max-width:90vw;
max-height:80vh; overflow-y:auto; }
.kb-modal-content h3 { margin-bottom:20px; }
.form-group { margin-bottom:14px; }
.form-group label { display:block; font-weight:600; margin-bottom:4px; font-size:13px; color:var(--text-muted); }
.form-group input, .form-group textarea, .form-group select {
width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:14px;
}
.form-group textarea { min-height:100px; resize:vertical; }
.modal-actions { display:flex; gap:12px; margin-top:16px; }
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; flex-wrap:wrap; }
.filter-bar input, .filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
</style>
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h2>База знаний чат-бота</h2>
<button class="btn btn-primary" onclick="openModal()">+ Добавить карточку</button>
</div>
<div class="filter-bar">
<input type="text" id="kb-search" placeholder="Поиск по вопросу..." oninput="filterKB()">
<select id="kb-category-filter" onchange="filterKB()">
<option value="">Все категории</option>
</select>
<select id="kb-active-filter" onchange="filterKB()">
<option value="">Все статусы</option>
<option value="true">Активные</option>
<option value="false">Неактивные</option>
</select>
</div>
<table class="kb-table">
<thead>
<tr>
<th>ID</th>
<th>Вопрос</th>
<th>Категория</th>
<th>Статус</th>
<th>Источник</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="kb-table-body">
<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div class="kb-modal" id="kb-modal">
<div class="kb-modal-content">
<h3 id="modal-title">Новая карточка</h3>
<form id="kb-form">
<div class="form-group">
<label>Категория</label>
<select name="category_id" id="kb-category-id"></select>
</div>
<div class="form-group">
<label>Вопрос *</label>
<input type="text" name="question" id="kb-question" required>
</div>
<div class="form-group">
<label>Ответ (оставьте пустым, если нет ответа)</label>
<textarea name="answer" id="kb-answer"></textarea>
</div>
<div class="form-group">
<label>Ключевые слова (через запятую)</label>
<input type="text" name="keywords" id="kb-keywords" placeholder="слово1, слово2, слово3">
</div>
<div class="form-group">
<label>URL источника</label>
<input type="url" name="source_url" id="kb-source-url" placeholder="https://aegisone.ru/...">
</div>
<div class="form-group">
<label><input type="checkbox" name="is_active" id="kb-is-active" checked> Активная карточка</label>
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn" onclick="closeModal()">Отмена</button>
</div>
</form>
</div>
</div>
<script>
var categories = {};
var allCards = [];
fetch('/service/api/bot/settings').then(function(r){ return r.json() }).then(function(d){});
function loadKB(){
fetch('/api/bot/kb').then(function(r){ return r.json() }).then(function(data){
allCards = data;
renderKB(data);
});
}
function renderKB(cards){
var tbody = document.getElementById('kb-table-body');
if(!cards.length){
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Нет карточек</td></tr>';
return;
}
var html = '';
cards.forEach(function(c){
var catName = categories[c.category_id] || 'Без категории';
var activeClass = c.is_active ? 'yes' : 'no';
var activeLabel = c.is_active ? 'Активна' : 'Неактивна';
var answerPreview = c.answer ? c.answer.substring(0, 50) + '...' : '<em style="color:var(--text-muted)">Нет ответа</em>';
html += '<tr>' +
'<td>' + c.id + '</td>' +
'<td><strong>' + escapeHtml(c.question) + '</strong><br><small style="color:var(--text-muted)">' + answerPreview + '</small></td>' +
'<td>' + catName + '</td>' +
'<td><span class="active-badge ' + activeClass + '">' + activeLabel + '</span></td>' +
'<td style="font-size:12px;">' + (c.source_url ? '<a href="' + c.source_url + '" target="_blank">ссылка</a>' : '—') + '</td>' +
'<td><button class="btn btn-sm" onclick="editCard(' + c.id + ')" title="Редактировать">✏️</button> <button class="btn btn-sm" onclick="deleteCard(' + c.id + ')" style="color:#dc3545;" title="Удалить">🗑</button></td>' +
'</tr>';
});
tbody.innerHTML = html;
}
function filterKB(){
var search = document.getElementById('kb-search').value.toLowerCase();
var catFilter = document.getElementById('kb-category-filter').value;
var activeFilter = document.getElementById('kb-active-filter').value;
var filtered = allCards.filter(function(c){
if(search && !c.question.toLowerCase().includes(search)) return false;
if(catFilter && String(c.category_id) !== catFilter) return false;
if(activeFilter !== '' && String(c.is_active) !== activeFilter) return false;
return true;
});
renderKB(filtered);
}
function openModal(){
editingId = null;
document.getElementById('kb-modal').classList.add('open');
document.getElementById('modal-title').textContent = 'Новая карточка';
document.getElementById('kb-form').reset();
document.getElementById('kb-is-active').checked = true;
}
function closeModal(){
document.getElementById('kb-modal').classList.remove('open');
}
var editingId = null;
function editCard(id){
var card = allCards.find(function(c){ return c.id === id; });
if(!card) return;
editingId = id;
document.getElementById('modal-title').textContent = 'Редактировать карточку #' + id;
document.getElementById('kb-question').value = card.question || '';
document.getElementById('kb-answer').value = card.answer || '';
document.getElementById('kb-keywords').value = (card.keywords || []).join(', ');
document.getElementById('kb-source-url').value = card.source_url || '';
document.getElementById('kb-is-active').checked = card.is_active;
document.getElementById('kb-category-id').value = card.category_id || '';
document.getElementById('kb-modal').classList.add('open');
}
function deleteCard(id){
if(!confirm('Удалить карточку?')) return;
fetch('/api/bot/kb/' + id, { method: 'DELETE' }).then(function(r){ return r.json() }).then(function(d){
if(d.ok) { showNotification('Карточка удалена', 'success'); loadKB(); }
else showNotification('Ошибка удаления', 'error');
});
}
document.getElementById('kb-form').addEventListener('submit', function(e){
e.preventDefault();
var data = {
category_id: parseInt(document.getElementById('kb-category-id').value) || null,
question: document.getElementById('kb-question').value,
answer: document.getElementById('kb-answer').value || null,
keywords: document.getElementById('kb-keywords').value.split(',').map(function(s){ return s.trim(); }).filter(Boolean),
source_url: document.getElementById('kb-source-url').value || null,
is_active: document.getElementById('kb-is-active').checked,
sort_order: 0,
};
var url = '/api/bot/kb';
var method = 'POST';
if(editingId) { url += '/' + editingId; method = 'PUT'; }
fetch(url, {
method: method, headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
}).then(function(r){ return r.json() }).then(function(d){
if(d.ok) { closeModal(); loadKB(); showNotification(editingId ? 'Карточка обновлена' : 'Карточка создана', 'success'); }
else showNotification('Ошибка сохранения', 'error');
});
});
function escapeHtml(s){
if(!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Load categories
fetch('/api/bot/kb').then(function(r){ return r.json() }).then(function(data){
// Extract unique categories
var catSet = {};
data.forEach(function(c){
if(c.category_id) catSet[c.category_id] = true;
});
var catSelect = document.getElementById('kb-category-id');
var catFilter = document.getElementById('kb-category-filter');
var catOptions = {
1: 'О компании', 2: 'Аудит безопасности', 3: 'Сервисное обслуживание (SLA)',
4: 'Реагирование на инциденты', 5: 'Технический надзор',
6: 'Документация и нормативы', 7: 'Риск-инжиниринг', 8: 'Отрасли и кейсы'
};
categories = catOptions;
Object.keys(catOptions).forEach(function(id){
var opt1 = document.createElement('option');
opt1.value = id; opt1.textContent = catOptions[id];
catSelect.appendChild(opt1);
var opt2 = document.createElement('option');
opt2.value = id; opt2.textContent = catOptions[id];
catFilter.appendChild(opt2);
});
});
loadKB();
</script>
{% endblock %}