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:
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// 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 %}
|
||||
Reference in New Issue
Block a user