Files
hotels/web_interface.py
Фёдор 0cf3297290 Проект аудита отелей: основные скрипты и документация
- Краулеры: smart_crawler.py, regional_crawler.py
- Аудит: audit_orel_to_excel.py, audit_chukotka_to_excel.py
- РКН проверка: check_rkn_registry.py, recheck_unclear_rkn.py
- Отчёты: create_orel_horizontal_report.py
- Обработка: process_all_hotels_embeddings.py
- Документация: README.md, DB_SCHEMA_REFERENCE.md
2025-10-16 10:52:09 +03:00

2194 lines
92 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Веб-интерфейс для системы аудита отелей
- Дашборд со статистикой
- Управление критериями
- Чат-бот с GPT-4o-mini
- Интеграция с Graphiti и PostgreSQL
"""
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from typing import List, Dict, Optional
import psycopg2
from psycopg2.extras import RealDictCursor
from urllib.parse import unquote
import requests
import os
from datetime import datetime
# Импортируем LLM клиент
from llm_client import llm
from llm_config import get_model_info, get_available_models, ACTIVE_PROVIDER, OPENAI_CONFIG, OPENROUTER_CONFIG, OLLAMA_CONFIG
from model_providers import get_all_models
from memory_agent import memory_agent
from user_settings_manager import user_settings_manager
import json
app = FastAPI(
title="Hotel Audit Dashboard",
description="Система аудита отелей - Общественный контроль",
version="1.0.0"
)
# Конфигурация
DB_CONFIG = {
'host': "147.45.189.234",
'port': 5432,
'database': "default_db",
'user': "gen_user",
'password': unquote("2~~9_%5EkVsU%3F2%5CS")
}
# Конфигурация теперь в llm_config.py и llm_client.py
# Конфигурация для семантического поиска
BGE_API_URL = "http://147.45.146.17:8002/embed"
BGE_API_KEY = "22564b177aa73b6ac0b8642d7773350ff4c01d4983f028beff15ea247f09fa89"
def semantic_search_hotels(query: str, region: str = None, limit: int = 5):
"""Семантический поиск по эмбеддингам отелей"""
try:
# Генерируем эмбеддинг для запроса
headers = {
"X-API-Key": BGE_API_KEY,
"Content-Type": "application/json"
}
payload = {"text": [query]}
response = requests.post(BGE_API_URL, json=payload, headers=headers, timeout=30)
if response.status_code != 200:
return []
result = response.json()
query_embedding = result.get('embeddings', [[]])[0]
if not query_embedding:
return []
embedding_str = json.dumps(query_embedding)
# Строим SQL запрос с фильтрами
conn = get_db_connection()
cur = conn.cursor()
params = [embedding_str, embedding_str]
where_clause = "embedding IS NOT NULL"
if region:
where_clause += " AND metadata->>'region_name' = %s"
params.append(region)
params.append(limit)
query_sql = f"""
SELECT
metadata->>'hotel_name' as hotel_name,
metadata->>'region_name' as region_name,
metadata->>'url' as url,
LEFT(text, 500) as text,
embedding <-> %s::vector as distance
FROM hotel_website_chunks
WHERE {where_clause}
ORDER BY embedding <-> %s::vector
LIMIT %s;
"""
cur.execute(query_sql, params)
results = cur.fetchall()
cur.close()
conn.close()
return results
except Exception as e:
print(f"Ошибка семантического поиска: {e}")
return []
# Модели данных
class ChatMessage(BaseModel):
message: str
region: Optional[str] = None
group_id: Optional[str] = None
class CriterionUpdate(BaseModel):
id: int
name: str
query: str
keywords: List[str]
def get_db_connection():
"""Получить подключение к БД"""
return psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
# ==================== API ENDPOINTS ====================
@app.get("/", response_class=HTMLResponse)
async def root():
"""Главная страница с дашбордом"""
return """
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Аудит Отелей - Общественный Контроль</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #333;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
header {
background: white;
border-radius: 12px;
padding: 30px;
margin-bottom: 20px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}
h1 {
color: #667eea;
font-size: 2.5em;
margin-bottom: 10px;
}
.subtitle {
color: #666;
font-size: 1.1em;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.card {
background: white;
border-radius: 12px;
padding: 25px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}
.card h2 {
color: #667eea;
margin-bottom: 15px;
font-size: 1.3em;
}
.stat {
font-size: 2.5em;
font-weight: bold;
color: #764ba2;
margin: 10px 0;
}
.stat-label {
color: #666;
font-size: 0.9em;
}
.btn {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-size: 1em;
margin: 5px;
transition: transform 0.2s;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
.btn-small {
background: #4299e1;
color: white;
border: none;
padding: 6px 12px;
border-radius: 6px;
font-size: 0.9em;
cursor: pointer;
transition: all 0.2s;
}
.btn-small:hover {
background: #3182ce;
transform: translateY(-1px);
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.tab {
background: white;
border: none;
padding: 15px 30px;
border-radius: 8px;
cursor: pointer;
font-size: 1em;
transition: all 0.3s;
}
.tab.active {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.chat-container {
background: white;
border-radius: 12px;
padding: 20px;
height: 600px;
display: flex;
flex-direction: column;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 15px;
}
.message {
margin-bottom: 15px;
padding: 12px 16px;
border-radius: 8px;
max-width: 80%;
}
.message.user {
background: #667eea;
color: white;
margin-left: auto;
}
.message.assistant {
background: #e9ecef;
}
.chat-input {
display: flex;
gap: 10px;
}
.chat-input input {
flex: 1;
padding: 12px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 1em;
}
table {
width: 100%;
border-collapse: collapse;
background: white;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background: #667eea;
color: white;
}
tr:hover {
background: #f8f9fa;
}
.progress-bar {
background: #e9ecef;
border-radius: 8px;
height: 8px;
margin: 10px 0;
}
.progress-fill {
background: linear-gradient(135deg, #667eea, #764ba2);
height: 100%;
border-radius: 8px;
transition: width 0.3s;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🏨 Аудит Отелей - Общественный Контроль</h1>
<p class="subtitle">Система мониторинга соответствия требованиям законодательства</p>
</header>
<div class="tabs">
<button class="tab active" onclick="showTab('dashboard')">📊 Дашборд</button>
<button class="tab" onclick="showTab('regions')">🗺 Регионы</button>
<button class="tab" onclick="showTab('hotels')">🏨 Отели</button>
<button class="tab" onclick="showTab('chat')">💬 Чат-ассистент</button>
<button class="tab" onclick="showTab('models')">🤖 Модели</button>
<button class="tab" onclick="showTab('memory')">🧠 Память</button>
<button class="tab" onclick="showTab('criteria')">📋 Критерии</button>
</div>
<!-- Дашборд -->
<div id="dashboard" class="tab-content active">
<div class="grid">
<div class="card">
<h2>📊 Общая статистика</h2>
<div class="stat" id="total-hotels">-</div>
<div class="stat-label">Всего отелей в базе</div>
</div>
<div class="card">
<h2>🌐 Парсинг сайтов</h2>
<div class="stat" id="crawled-sites">-</div>
<div class="stat-label">Сайтов спарсено</div>
</div>
<div class="card">
<h2>✅ Аудит выполнен</h2>
<div class="stat" id="audited-hotels">-</div>
<div class="stat-label">Отелей проверено</div>
</div>
<div class="card">
<h2>📈 Средний балл</h2>
<div class="stat" id="avg-score">-</div>
<div class="stat-label">Из 18 критериев</div>
</div>
</div>
<div class="card">
<h2>🗺 Статус по регионам</h2>
<div id="regions-list">Загрузка...</div>
</div>
</div>
<!-- Регионы -->
<div id="regions" class="tab-content">
<div class="card">
<h2>🗺 Выбор региона для аудита</h2>
<select id="region-select" style="width: 100%; padding: 12px; font-size: 1em; margin: 10px 0;">
<option>Загрузка...</option>
</select>
<button class="btn" onclick="runRegionAudit()">🚀 Запустить аудит региона</button>
<button class="btn" onclick="exportRegion()">📊 Экспорт в Excel</button>
</div>
</div>
<!-- Отели -->
<div id="hotels" class="tab-content">
<div class="card">
<h2>🏨 База отелей</h2>
<input type="text" id="hotel-search" placeholder="Поиск отеля..."
style="width: 100%; padding: 12px; margin-bottom: 15px; border: 2px solid #ddd; border-radius: 8px;">
<div id="hotels-table">Загрузка...</div>
</div>
</div>
<!-- Чат -->
<div id="chat" class="tab-content">
<div class="card">
<h2>💬 Чат-ассистент (GPT-4o-mini)</h2>
<p style="color: #666; margin-bottom: 15px;">
Задавайте вопросы по данным отелей. Используется semantic search по Graphiti + PostgreSQL.
</p>
</div>
<div class="chat-container">
<div class="chat-messages" id="chat-messages">
<div class="message assistant">
Привет! Я помогу вам с анализом данных отелей. Спрашивайте!
</div>
</div>
<div class="chat-input">
<input type="text" id="chat-input" placeholder="Задайте вопрос..."
onkeypress="if(event.key==='Enter') sendMessage()">
<button class="btn" onclick="sendMessage()">Отправить</button>
</div>
</div>
</div>
<!-- Модели -->
<div id="models" class="tab-content">
<div class="card">
<h2>🤖 Управление LLM моделями</h2>
<p style="color: #666; margin-bottom: 15px;">
Переключение между OpenAI, OpenRouter, Ollama (как в n8n)
</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<div>
<h3>📊 Текущая модель</h3>
<div id="current-model-info" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
Загрузка...
</div>
</div>
<div>
<h3>🔄 Переключение провайдера</h3>
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Провайдер:</label>
<select id="provider-select" style="width: 100%; padding: 10px; border: 2px solid #ddd; border-radius: 8px; font-size: 1em;">
<option value="">Загрузка...</option>
</select>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Модель:</label>
<select id="model-select" style="width: 100%; padding: 10px; border: 2px solid #ddd; border-radius: 8px; font-size: 1em;">
<option value="">Загрузка...</option>
</select>
</div>
<div style="margin-top: 15px;">
<button class="btn" onclick="saveModelSettings()" style="width: 100%; padding: 12px; font-size: 1em; background: linear-gradient(135deg, #667eea, #764ba2);">
💾 Сохранить настройки
</button>
</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<h3>🎯 Все доступные модели (динамическая загрузка)</h3>
<button class="btn" onclick="loadDynamicModels()" style="margin-bottom: 15px;">🔄 Загрузить модели от провайдеров</button>
<div id="dynamic-models" style="background: #f8f9fa; padding: 15px; border-radius: 8px; max-height: 400px; overflow-y: auto;">
Нажмите "Загрузить модели" для получения списка всех доступных моделей
</div>
</div>
<div style="margin-bottom: 20px;">
<h3>🧪 Тест модели</h3>
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
<input type="text" id="test-prompt" placeholder="Введите тестовый вопрос..."
style="flex: 1; padding: 10px; border: 2px solid #ddd; border-radius: 8px;">
<button class="btn" onclick="testModel()">Тест</button>
</div>
<div id="test-result" style="background: #f8f9fa; padding: 15px; border-radius: 8px; min-height: 100px;">
Результат теста появится здесь...
</div>
</div>
<button class="btn" onclick="loadModelInfo()">🔄 Обновить информацию</button>
</div>
</div>
<!-- Память агента -->
<div id="memory" class="tab-content">
<div class="card">
<h2>🧠 Память агента</h2>
<p style="color: #666; margin-bottom: 15px;">
Интеграция с MCP сервером памяти агента для сохранения контекста разговоров
</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<div>
<h3>📊 Статус памяти</h3>
<div id="memory-status" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div id="memory-status-content">Проверка подключения...</div>
</div>
</div>
<div>
<h3>👤 Информация о пользователе</h3>
<div id="user-info" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div id="user-info-content">Определение пользователя...</div>
</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<h3>🔍 Поиск в памяти</h3>
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
<input type="text" id="memory-search-query" placeholder="Введите запрос для поиска в памяти..."
style="flex: 1; padding: 10px; border: 2px solid #ddd; border-radius: 8px;">
<button class="btn" onclick="searchMemory()">🔍 Поиск</button>
</div>
<div id="memory-search-results" style="background: #f8f9fa; padding: 15px; border-radius: 8px; max-height: 300px; overflow-y: auto;">
Результаты поиска появятся здесь
</div>
</div>
<div style="margin-bottom: 20px;">
<h3>📚 История пользователя</h3>
<button class="btn" onclick="loadUserHistory()" style="margin-bottom: 15px;">📖 Загрузить историю</button>
<div id="user-history" style="background: #f8f9fa; padding: 15px; border-radius: 8px; max-height: 400px; overflow-y: auto;">
История разговоров появится здесь
</div>
</div>
<div style="margin-bottom: 20px;">
<h3>💾 Добавить память</h3>
<div style="margin-bottom: 10px;">
<textarea id="memory-content" placeholder="Введите информацию для сохранения в память..."
style="width: 100%; height: 100px; padding: 10px; border: 2px solid #ddd; border-radius: 8px; resize: vertical;"></textarea>
</div>
<div style="display: flex; gap: 10px;">
<select id="memory-source" style="padding: 10px; border: 2px solid #ddd; border-radius: 8px;">
<option value="chat">Чат</option>
<option value="manual">Ручной ввод</option>
<option value="analysis">Анализ</option>
</select>
<button class="btn" onclick="addMemory()">💾 Сохранить</button>
</div>
</div>
</div>
</div>
<!-- Критерии -->
<div id="criteria" class="tab-content">
<div class="card">
<h2>📋 Управление критериями аудита</h2>
<p style="color: #666; margin-bottom: 15px;">
18 критериев онлайн-аудита сайтов отелей
</p>
<button class="btn" onclick="loadCriteria()">🔄 Загрузить критерии</button>
<button class="btn" onclick="addCriterion()"> Добавить критерий</button>
<div id="criteria-list" style="margin-top: 20px;">Загрузка...</div>
</div>
</div>
</div>
<script>
// Переключение табов
function showTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(tabName).classList.add('active');
// Загружаем данные при открытии таба
if (tabName === 'dashboard') loadDashboard();
if (tabName === 'regions') loadRegions();
if (tabName === 'hotels') loadHotels();
if (tabName === 'models') loadModelInfo();
if (tabName === 'criteria') loadCriteria();
}
// Загрузка дашборда
async function loadDashboard() {
const response = await fetch('/api/stats');
const data = await response.json();
document.getElementById('total-hotels').textContent = data.total_hotels.toLocaleString();
document.getElementById('crawled-sites').textContent = data.crawled_sites.toLocaleString();
document.getElementById('audited-hotels').textContent = data.audited_hotels.toLocaleString();
document.getElementById('avg-score').textContent = data.avg_score.toFixed(1);
// Регионы
let html = '<table><tr><th>Регион</th><th>Отелей</th><th>Спарсено</th><th>Проверено</th><th>Средний балл</th><th>Действия</th></tr>';
data.regions.forEach(r => {
const downloadBtn = r.audited > 0 ?
`<button class="btn-small" onclick="downloadAudit('${r.region_name}')" title="Скачать Excel отчет">📥 Скачать</button>` :
'<span style="color: #999;">Нет данных</span>';
const chartBtn = r.audited > 0 ?
`<button class="btn-small" onclick="showRegionChart('${r.region_name}', ${r.total_hotels}, ${r.crawled || 0}, ${r.audited || 0}, ${r.avg_score || 0})" title="Показать диаграммы">📊 Графики</button>` :
'<span style="color: #999;">-</span>';
html += `<tr>
<td>${r.region_name}</td>
<td>${r.total_hotels}</td>
<td>${r.crawled || 0}</td>
<td>${r.audited || 0}</td>
<td>${r.avg_score ? r.avg_score.toFixed(1) : '-'}/18</td>
<td>${downloadBtn} ${chartBtn}</td>
</tr>`;
});
html += '</table>';
document.getElementById('regions-list').innerHTML = html;
}
// Скачивание отчета по аудиту
function downloadAudit(regionName) {
const url = `/api/audit/download/${encodeURIComponent(regionName)}`;
const link = document.createElement('a');
link.href = url;
link.download = `audit_${regionName.replace(/\s+/g, '_')}.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// Показать диаграммы региона
function showRegionChart(regionName, totalHotels, crawled, audited, avgScore) {
// Создаем модальное окно
const modal = document.createElement('div');
modal.id = 'chart-modal';
modal.style.cssText = `
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.8); z-index: 10000; display: flex;
align-items: center; justify-content: center;
`;
modal.innerHTML = `
<div style="background: white; padding: 30px; border-radius: 15px; max-width: 90%; max-height: 90%; overflow-y: auto; position: relative;">
<button onclick="closeChartModal()" style="position: absolute; top: 10px; right: 15px; background: #e74c3c; color: white; border: none; border-radius: 50%; width: 30px; height: 30px; cursor: pointer; font-size: 18px;">×</button>
<h2 style="color: #2c3e50; margin-bottom: 20px; text-align: center;">📊 Аналитика: ${regionName}</h2>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<div>
<h3>📈 Общая статистика</h3>
<canvas id="overviewChart" width="400" height="200"></canvas>
</div>
<div>
<h3>🎯 Покрытие аудитом</h3>
<canvas id="coverageChart" width="400" height="200"></canvas>
</div>
</div>
<div style="text-align: center;">
<h3>📊 Детальная статистика</h3>
<canvas id="detailedChart" width="800" height="300"></canvas>
</div>
<div style="text-align: center; margin-top: 20px;">
<h3>📋 Результаты аудита по критериям</h3>
<canvas id="auditResultsChart" width="1000" height="400"></canvas>
</div>
<div style="margin-top: 20px; text-align: center;">
<button class="btn" onclick="downloadAudit('${regionName}')">📥 Скачать Excel отчет</button>
<button class="btn" onclick="closeChartModal()" style="background: #95a5a6;">❌ Закрыть</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Инициализируем диаграммы после небольшой задержки
setTimeout(() => {
initRegionCharts(regionName, totalHotels, crawled, audited, avgScore);
loadAuditResults(regionName);
}, 100);
}
// Закрыть модальное окно
function closeChartModal() {
const modal = document.getElementById('chart-modal');
if (modal) {
modal.remove();
}
}
// Инициализация диаграмм
function initRegionCharts(regionName, totalHotels, crawled, audited, avgScore) {
// Загружаем Chart.js если не загружен
if (typeof Chart === 'undefined') {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/chart.js';
script.onload = () => createCharts(regionName, totalHotels, crawled, audited, avgScore);
document.head.appendChild(script);
} else {
createCharts(regionName, totalHotels, crawled, audited, avgScore);
}
}
// Создание диаграмм
function createCharts(regionName, totalHotels, crawled, audited, avgScore) {
// 1. Общая статистика (столбчатая диаграмма)
const overviewCtx = document.getElementById('overviewChart').getContext('2d');
new Chart(overviewCtx, {
type: 'bar',
data: {
labels: ['Всего отелей', 'Спарсено', 'Проверено'],
datasets: [{
label: 'Количество',
data: [totalHotels, crawled, audited],
backgroundColor: ['#3498db', '#e74c3c', '#27ae60'],
borderColor: ['#2980b9', '#c0392b', '#229954'],
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
title: { display: false }
},
scales: {
y: { beginAtZero: true }
}
}
});
// 2. Покрытие аудитом (круговая диаграмма)
const coverageCtx = document.getElementById('coverageChart').getContext('2d');
new Chart(coverageCtx, {
type: 'doughnut',
data: {
labels: ['Проверено', 'Не проверено'],
datasets: [{
data: [audited, totalHotels - audited],
backgroundColor: ['#27ae60', '#ecf0f1'],
borderColor: ['#229954', '#bdc3c7'],
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' },
title: { display: false }
}
}
});
// 3. Детальная статистика (радар)
const detailedCtx = document.getElementById('detailedChart').getContext('2d');
new Chart(detailedCtx, {
type: 'radar',
data: {
labels: ['Покрытие сайтами', 'Покрытие аудитом', 'Средний балл', 'Эффективность парсинга'],
datasets: [{
label: regionName,
data: [
(crawled / totalHotels * 100).toFixed(1),
(audited / totalHotels * 100).toFixed(1),
(avgScore / 18 * 100).toFixed(1),
(crawled / audited * 100).toFixed(1)
],
backgroundColor: 'rgba(52, 152, 219, 0.2)',
borderColor: 'rgba(52, 152, 219, 1)',
borderWidth: 2,
pointBackgroundColor: 'rgba(52, 152, 219, 1)',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: 'rgba(52, 152, 219, 1)'
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
title: { display: false }
},
scales: {
r: {
beginAtZero: true,
max: 100,
ticks: { stepSize: 20 }
}
}
}
});
}
// Загрузка результатов аудита по критериям
async function loadAuditResults(regionName) {
try {
const response = await fetch('/api/audit/criteria-stats', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
region: regionName
})
});
const data = await response.json();
if (data.status === 'success') {
createAuditResultsChart(data.criteria_stats, regionName);
} else {
console.error('Ошибка загрузки данных аудита:', data.error);
// Создаем заглушку
createAuditResultsChart([], regionName);
}
} catch (error) {
console.error('Ошибка загрузки данных аудита:', error);
createAuditResultsChart([], regionName);
}
}
// Создание диаграммы результатов аудита
function createAuditResultsChart(criteriaStats, regionName) {
const ctx = document.getElementById('auditResultsChart').getContext('2d');
if (criteriaStats.length === 0) {
// Заглушка если нет данных
new Chart(ctx, {
type: 'bar',
data: {
labels: ['Данные загружаются...'],
datasets: [{
label: 'Загрузка',
data: [0],
backgroundColor: '#ecf0f1'
}]
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: 'Загрузка данных аудита...'
}
}
}
});
return;
}
// Подготавливаем данные
const labels = criteriaStats.map(item => item.criterion_name);
const yesData = criteriaStats.map(item => item.yes_percentage);
const partialData = criteriaStats.map(item => item.partial_percentage);
const noData = criteriaStats.map(item => item.no_percentage);
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: 'ДА',
data: yesData,
backgroundColor: '#27ae60',
borderColor: '#229954',
borderWidth: 1
},
{
label: 'ЧАСТИЧНО',
data: partialData,
backgroundColor: '#f39c12',
borderColor: '#e67e22',
borderWidth: 1
},
{
label: 'НЕТ',
data: noData,
backgroundColor: '#e74c3c',
borderColor: '#c0392b',
borderWidth: 1
}
]
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: `${regionName}: Соответствие критериям аудита (%)`,
font: { size: 16 }
},
legend: {
position: 'top'
}
},
scales: {
x: {
ticks: {
maxRotation: 45,
minRotation: 45
}
},
y: {
beginAtZero: true,
max: 100,
title: {
display: true,
text: 'Доля объектов, %'
}
}
},
interaction: {
mode: 'index',
intersect: false
}
}
});
}
// Загрузка регионов
async function loadRegions() {
const response = await fetch('/api/regions');
const regions = await response.json();
let html = '<option value="">Выберите регион...</option>';
regions.forEach(r => {
html += `<option value="${r.name}">${r.name} (${r.count} отелей)</option>`;
});
document.getElementById('region-select').innerHTML = html;
}
// Загрузка отелей
async function loadHotels(search = '') {
const response = await fetch(`/api/hotels?search=${search}`);
const hotels = await response.json();
let html = '<table><tr><th>Отель</th><th>Регион</th><th>Категория</th><th>Сайт</th><th>Балл аудита</th></tr>';
hotels.forEach(h => {
const site = h.website ? `<a href="${h.website}" target="_blank">🌐</a>` : '';
const score = h.audit_score !== null ? `${h.audit_score}/18` : '-';
html += `<tr onclick="showHotelDetails('${h.id}')">
<td>${h.name}</td>
<td>${h.region}</td>
<td>${h.category}</td>
<td>${site}</td>
<td>${score}</td>
</tr>`;
});
html += '</table>';
document.getElementById('hotels-table').innerHTML = html;
}
// Поиск отелей
document.addEventListener('DOMContentLoaded', () => {
const searchInput = document.getElementById('hotel-search');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
loadHotels(e.target.value);
});
}
// Инициализация памяти агента
loadMemoryStatus();
loadUserInfo();
});
// Чат
async function sendMessage() {
const input = document.getElementById('chat-input');
const message = input.value.trim();
if (!message) return;
// Добавляем сообщение пользователя
const messagesDiv = document.getElementById('chat-messages');
messagesDiv.innerHTML += `<div class="message user">${message}</div>`;
input.value = '';
messagesDiv.scrollTop = messagesDiv.scrollHeight;
// Отправляем запрос
const response = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: message})
});
const data = await response.json();
messagesDiv.innerHTML += `<div class="message assistant">${data.response}</div>`;
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
// Загрузка критериев
async function loadCriteria() {
const response = await fetch('/api/criteria');
const criteria = await response.json();
let html = '<table><tr><th>№</th><th>Критерий</th><th>Keywords</th><th>Действия</th></tr>';
criteria.forEach(c => {
html += `<tr>
<td>${c.id}</td>
<td>${c.name}</td>
<td>${c.keywords.join(', ')}</td>
<td><button class="btn" onclick="editCriterion(${c.id})">✏️</button></td>
</tr>`;
});
html += '</table>';
document.getElementById('criteria-list').innerHTML = html;
}
// Запуск аудита региона
async function runRegionAudit() {
const region = document.getElementById('region-select').value;
if (!region) {
alert('Выберите регион!');
return;
}
if (!confirm(`Запустить аудит для региона "${region}"?`)) return;
alert('Аудит запущен в фоне. Результаты будут доступны через несколько минут.');
fetch('/api/audit/run', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({region: region, force_audit: true})
});
}
// Управление моделями
async function loadModelInfo() {
try {
// Загружаем информацию о текущей модели
const infoResponse = await fetch('/api/llm/info');
const info = await infoResponse.json();
document.getElementById('current-model-info').innerHTML = `
<div style="font-size: 1.1em; margin-bottom: 10px;">
<strong>${info.provider.toUpperCase()}</strong>
</div>
<div style="margin-bottom: 5px;">
<strong>Модель:</strong> ${info.chat_model}
</div>
<div style="margin-bottom: 5px;">
<strong>Temperature:</strong> ${info.temperature}
</div>
<div style="margin-bottom: 5px;">
<strong>Max tokens:</strong> ${info.max_tokens}
</div>
<div style="margin-bottom: 5px;">
<strong>Прокси:</strong> ${info.uses_proxy ? '✅ Да' : '❌ Нет'}
</div>
`;
// Загружаем провайдеров
await loadProviders();
} catch (error) {
console.error('Ошибка загрузки информации о моделях:', error);
document.getElementById('current-model-info').innerHTML = 'Ошибка загрузки';
}
}
async function loadProviders() {
try {
const response = await fetch('/api/llm/providers');
const data = await response.json();
const providerSelect = document.getElementById('provider-select');
providerSelect.innerHTML = '';
for (const provider of data.available) {
const option = document.createElement('option');
option.value = provider.id;
option.textContent = `${provider.name} ${provider.has_key ? '' : ''} - ${provider.description}`;
if (provider.id === data.current) {
option.selected = true;
}
providerSelect.appendChild(option);
}
// Загружаем модели для текущего провайдера
await loadModelsForProvider(data.current);
console.log(`✅ Загружен провайдер: ${data.current}`);
} catch (error) {
console.error('Ошибка загрузки провайдеров:', error);
}
}
async function loadModelsForProvider(provider) {
try {
const modelSelect = document.getElementById('model-select');
modelSelect.innerHTML = '';
// Получаем модели из БД
const response = await fetch('/api/llm/models-dynamic');
const data = await response.json();
if (data.status === 'success' && data.providers[provider]) {
const models = data.providers[provider];
for (const model of models) {
const option = document.createElement('option');
option.value = model.model_id;
option.textContent = `${model.model_name} - ${model.description || ''}`;
modelSelect.appendChild(option);
}
console.log(`✅ Загружено ${models.length} моделей для ${provider}`);
} else {
console.error('❌ Ошибка загрузки моделей:', data);
// Fallback к статическому списку
const fallbackModels = {
'openai': ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo'],
'openrouter': ['anthropic/claude-3-haiku', 'anthropic/claude-3-sonnet'],
'ollama': ['llama3.1', 'codellama']
};
const models = fallbackModels[provider] || ['gpt-4o-mini'];
for (const model of models) {
const option = document.createElement('option');
option.value = model;
option.textContent = model;
modelSelect.appendChild(option);
}
}
} catch (error) {
console.error('Ошибка загрузки моделей:', error);
}
}
async function saveModelSettings() {
const providerSelect = document.getElementById('provider-select');
const modelSelect = document.getElementById('model-select');
const provider = providerSelect.value;
const model = modelSelect.value;
if (!provider || !model) {
alert('Выберите провайдера и модель!');
return;
}
try {
// Сохраняем настройки пользователя в БД
const response = await fetch('/api/llm/save-user-settings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({provider: provider, model: model})
});
const result = await response.json();
if (result.status === 'success') {
console.log(`✅ Настройки сохранены в БД:`, result);
alert(`✅ Настройки сохранены!\nПровайдер: ${provider}\nМодель: ${model}\nUser ID: ${result.user_id}`);
// Обновляем информацию
await loadModelInfo();
console.log('🔄 Интерфейс обновлен');
} else {
console.error('❌ Ошибка сохранения:', result);
alert(`Ошибка сохранения: ${result.message || result.error}`);
}
} catch (error) {
console.error('❌ Ошибка сохранения настроек:', error);
alert('Ошибка сохранения настроек');
}
}
// Функция удалена - теперь используется кнопка "Сохранить настройки"
async function selectModelFromDynamic(modelId, provider) {
// Устанавливаем значения в селекты
const providerSelect = document.getElementById('provider-select');
const modelSelect = document.getElementById('model-select');
providerSelect.value = provider;
modelSelect.value = modelId;
// Показываем уведомление
alert(`Выбрана модель: ${modelId} (${provider})\nНажмите "Сохранить настройки" для применения`);
}
async function loadDynamicModels() {
const dynamicDiv = document.getElementById('dynamic-models');
dynamicDiv.innerHTML = '⏳ Загружаю модели от всех провайдеров...';
try {
const response = await fetch('/api/llm/models-dynamic');
const data = await response.json();
if (data.status === 'success') {
let html = `<div style="margin-bottom: 15px;"><strong>Всего моделей: ${data.total_models}</strong></div>`;
for (const [provider, models] of Object.entries(data.providers)) {
html += `<div style="margin-bottom: 20px;">`;
html += `<h4 style="color: #667eea; margin-bottom: 10px;">${provider.toUpperCase()} (${models.length} моделей)</h4>`;
for (const model of models) {
const priceText = model.pricing.input > 0 ?
`$${model.pricing.input}/${model.pricing.output} за 1M токенов` :
'Бесплатно';
html += `
<div style="padding: 10px; margin: 5px 0; background: white; border-radius: 5px; border-left: 3px solid #667eea; display: flex; justify-content: space-between; align-items: center;">
<div style="flex: 1;">
<div style="font-weight: bold;">${model.name}</div>
<div style="font-size: 0.9em; color: #666;">${model.description}</div>
<div style="font-size: 0.8em; color: #999;">
Контекст: ${model.context_length ? (model.context_length / 1000).toFixed(0) + 'K' : 'N/A'} |
Цена: ${priceText}
</div>
</div>
<button class="btn" onclick="selectModelFromDynamic('${model.id}', '${provider}')" style="margin-left: 10px; padding: 5px 10px; font-size: 0.8em;">
Выбрать
</button>
</div>
`;
}
html += `</div>`;
}
dynamicDiv.innerHTML = html;
} else {
dynamicDiv.innerHTML = `❌ Ошибка: ${data.error}`;
}
} catch (error) {
console.error('Ошибка загрузки динамических моделей:', error);
dynamicDiv.innerHTML = `❌ Ошибка загрузки: ${error.message}`;
}
}
// ===== ФУНКЦИИ ДЛЯ ПАМЯТИ АГЕНТА =====
async function loadMemoryStatus() {
try {
const response = await fetch('/api/memory/status');
const data = await response.json();
const statusDiv = document.getElementById('memory-status-content');
if (data.status === 'success') {
statusDiv.innerHTML = `✅ ${data.message}`;
} else {
statusDiv.innerHTML = `❌ ${data.error}`;
}
} catch (error) {
document.getElementById('memory-status-content').innerHTML = `❌ Ошибка: ${error.message}`;
}
}
async function loadUserInfo() {
try {
// Получаем информацию о пользователе из заголовков
const response = await fetch('/api/memory/status');
const data = await response.json();
const userInfoDiv = document.getElementById('user-info-content');
userInfoDiv.innerHTML = `
<div><strong>Статус:</strong> ${data.status === 'success' ? '✅ Подключен' : '❌ Ошибка'}</div>
<div><strong>MCP Сервер:</strong> http://185.197.75.249:9000</div>
<div><strong>User ID:</strong> Определяется по IP адресу</div>
<div><strong>Group ID:</strong> user_[IP_ADDRESS]</div>
`;
} catch (error) {
document.getElementById('user-info-content').innerHTML = `❌ Ошибка: ${error.message}`;
}
}
async function searchMemory() {
const query = document.getElementById('memory-search-query').value;
if (!query.trim()) {
alert('Введите запрос для поиска');
return;
}
try {
const response = await fetch('/api/memory/search', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
user_id: 'current_user', // Будет заменен на реальный user_id
query: query,
max_results: 10
})
});
const data = await response.json();
const resultsDiv = document.getElementById('memory-search-results');
if (data.status === 'success' && data.data) {
let html = `<h4>Результаты поиска (${data.data.length}):</h4>`;
data.data.forEach((fact, index) => {
html += `
<div style="margin: 10px 0; padding: 10px; background: white; border-radius: 5px; border-left: 3px solid #667eea;">
<div style="font-weight: bold;">Факт ${index + 1}:</div>
<div>${fact.content || 'Нет содержимого'}</div>
<div style="font-size: 0.8em; color: #666; margin-top: 5px;">
UUID: ${fact.uuid || 'N/A'} | Группа: ${fact.group_id || 'N/A'}
</div>
</div>
`;
});
resultsDiv.innerHTML = html;
} else {
resultsDiv.innerHTML = `❌ Ошибка поиска: ${data.error || 'Неизвестная ошибка'}`;
}
} catch (error) {
document.getElementById('memory-search-results').innerHTML = `❌ Ошибка: ${error.message}`;
}
}
async function loadUserHistory() {
try {
const response = await fetch('/api/memory/history/current_user');
const data = await response.json();
const historyDiv = document.getElementById('user-history');
if (data.status === 'success' && data.data) {
let html = `<h4>История пользователя (${data.data.length} записей):</h4>`;
data.data.forEach((episode, index) => {
html += `
<div style="margin: 10px 0; padding: 10px; background: white; border-radius: 5px; border-left: 3px solid #667eea;">
<div style="font-weight: bold;">Эпизод ${index + 1}:</div>
<div style="margin: 5px 0;">${episode.name || 'Без названия'}</div>
<div style="font-size: 0.9em; color: #666;">${episode.episode_body || 'Нет содержимого'}</div>
<div style="font-size: 0.8em; color: #999; margin-top: 5px;">
Источник: ${episode.source || 'N/A'} | UUID: ${episode.uuid || 'N/A'}
</div>
</div>
`;
});
historyDiv.innerHTML = html;
} else {
historyDiv.innerHTML = `❌ Ошибка загрузки истории: ${data.error || 'Неизвестная ошибка'}`;
}
} catch (error) {
document.getElementById('user-history').innerHTML = `❌ Ошибка: ${error.message}`;
}
}
async function addMemory() {
const content = document.getElementById('memory-content').value;
const source = document.getElementById('memory-source').value;
if (!content.trim()) {
alert('Введите содержимое для сохранения');
return;
}
try {
const response = await fetch('/api/memory/add', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
content: content,
source: source,
metadata: {
timestamp: new Date().toISOString(),
added_via: 'web_interface'
}
})
});
const data = await response.json();
if (data.status === 'success') {
alert('✅ Память успешно сохранена!');
document.getElementById('memory-content').value = '';
loadUserHistory(); // Обновляем историю
} else {
alert(`❌ Ошибка сохранения: ${data.error || 'Неизвестная ошибка'}`);
}
} catch (error) {
alert(`❌ Ошибка: ${error.message}`);
}
}
async function switchModel(model) {
try {
const response = await fetch('/api/llm/switch', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model: model})
});
const result = await response.json();
if (result.status === 'switched') {
console.log(`✅ Модель переключена на: ${result.new_model}`);
console.log(`📋 Провайдер: ${result.provider}`);
loadModelInfo(); // Обновляем информацию
} else {
console.error('❌ Ошибка переключения модели:', result);
}
} catch (error) {
console.error('❌ Ошибка переключения модели:', error);
}
}
async function testModel() {
const prompt = document.getElementById('test-prompt').value.trim();
if (!prompt) {
alert('Введите тестовый вопрос!');
return;
}
const resultDiv = document.getElementById('test-result');
resultDiv.innerHTML = '⏳ Тестирую модель...';
try {
const startTime = Date.now();
const response = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: prompt})
});
const data = await response.json();
const endTime = Date.now();
const duration = endTime - startTime;
resultDiv.innerHTML = `
<div style="margin-bottom: 10px;">
<strong>Вопрос:</strong> ${prompt}
</div>
<div style="margin-bottom: 10px;">
<strong>Время ответа:</strong> ${duration}ms
</div>
<div style="margin-bottom: 10px;">
<strong>Ответ:</strong>
</div>
<div style="background: white; padding: 10px; border-radius: 5px; border-left: 4px solid #667eea;">
${data.response}
</div>
`;
} catch (error) {
console.error('Ошибка тестирования модели:', error);
resultDiv.innerHTML = `❌ Ошибка: ${error.message}`;
}
}
// Инициализация
loadDashboard();
</script>
</body>
</html>
"""
@app.get("/api/stats")
async def get_stats():
"""Общая статистика"""
conn = get_db_connection()
cur = conn.cursor()
# Общие данные
cur.execute("SELECT count(*) FROM hotel_main;")
total_hotels = cur.fetchone()['count']
cur.execute("SELECT count(DISTINCT hotel_id) FROM hotel_website_raw;")
crawled_sites = cur.fetchone()['count']
cur.execute("SELECT count(*) FROM hotel_audit_results;")
audited = cur.fetchone()['count']
cur.execute("SELECT avg(total_score) FROM hotel_audit_results;")
avg_score = cur.fetchone()['avg'] or 0
# По регионам
cur.execute("""
SELECT
m.region_name,
count(DISTINCT m.id) as total_hotels,
count(DISTINCT w.hotel_id) as crawled,
count(DISTINCT a.hotel_id) as audited,
avg(a.total_score) as avg_score
FROM hotel_main m
LEFT JOIN hotel_website_raw w ON m.id = w.hotel_id
LEFT JOIN hotel_audit_results a ON m.id = a.hotel_id
WHERE m.region_name IS NOT NULL
GROUP BY m.region_name
HAVING count(DISTINCT a.hotel_id) > 0
ORDER BY avg_score DESC NULLS LAST
LIMIT 20;
""")
regions = cur.fetchall()
cur.close()
conn.close()
return {
'total_hotels': total_hotels,
'crawled_sites': crawled_sites,
'audited_hotels': audited,
'avg_score': float(avg_score),
'regions': [dict(r) for r in regions]
}
@app.get("/api/regions")
async def get_regions():
"""Список регионов"""
conn = get_db_connection()
cur = conn.cursor()
cur.execute("""
SELECT region_name, count(*) as count
FROM hotel_main
WHERE region_name IS NOT NULL
GROUP BY region_name
ORDER BY count DESC;
""")
regions = [{'name': r['region_name'], 'count': r['count']} for r in cur.fetchall()]
cur.close()
conn.close()
return regions
@app.get("/api/hotels")
async def get_hotels(search: str = '', limit: int = 100):
"""Список отелей"""
conn = get_db_connection()
cur = conn.cursor()
query = """
SELECT
m.id,
m.full_name as name,
m.region_name as region,
m.category_name as category,
r.main_data->>'websiteAddress' as website,
a.total_score as audit_score
FROM hotel_main m
LEFT JOIN hotel_raw_json r ON m.id = r.hotel_id
LEFT JOIN hotel_audit_results a ON m.id = a.hotel_id
WHERE m.full_name ILIKE %s
ORDER BY m.full_name
LIMIT %s;
"""
cur.execute(query, (f'%{search}%', limit))
hotels = [dict(r) for r in cur.fetchall()]
cur.close()
conn.close()
return hotels
@app.post("/api/chat")
async def chat(request: Request, message: ChatMessage):
"""Чат-бот с GPT-4o-mini с полным контекстом и памятью агента"""
try:
# Получаем user_id для памяти
user_id = memory_agent.get_user_id(request)
# Ищем в памяти агента релевантную информацию
memory_context = ""
try:
memory_search = mcp_memory_search_memory_facts(query=message.message, group_ids=[user_id], max_facts=3)
if memory_search:
memory_context = "\n\nРЕЛЕВАНТНАЯ ИНФОРМАЦИЯ ИЗ ПАМЯТИ:\n"
for fact in memory_search[:3]:
memory_context += f"- {fact.get('content', '')}\n"
except Exception as e:
print(f"Ошибка поиска в памяти: {e}")
# 🔍 СЕМАНТИЧЕСКИЙ ПОИСК по эмбеддингам
semantic_results = []
semantic_context = ""
try:
semantic_results = semantic_search_hotels(message.message, region=message.region, limit=5)
if semantic_results:
semantic_context = "\n\n🔍 РЕЛЕВАНТНАЯ ИНФОРМАЦИЯ ИЗ СЕМАНТИЧЕСКОГО ПОИСКА:\n"
for idx, result in enumerate(semantic_results, 1):
distance = result.get('distance', 1.0)
relevance = "🟢 Высокая" if distance < 0.9 else "🟡 Средняя" if distance < 1.0 else "🔴 Низкая"
semantic_context += f"\n{idx}. {result.get('hotel_name', 'Неизвестный отель')} ({result.get('region_name', 'Неизвестный регион')})\n"
semantic_context += f" Релевантность: {relevance} (расстояние: {distance:.3f})\n"
semantic_context += f" URL: {result.get('url', 'Нет URL')}\n"
semantic_context += f" Текст: {result.get('text', 'Нет текста')[:300]}...\n"
except Exception as e:
print(f"Ошибка семантического поиска: {e}")
conn = get_db_connection()
cur = conn.cursor()
# Ищем упоминания регионов в вопросе
cur.execute("""
SELECT DISTINCT region_name
FROM hotel_main
WHERE region_name IS NOT NULL
ORDER BY region_name;
""")
all_regions = [r['region_name'] for r in cur.fetchall()]
# Находим упомянутые регионы (улучшенный поиск)
mentioned_regions = []
message_lower = message.message.lower()
for region in all_regions:
region_lower = region.lower()
# Ищем частичные совпадения
if any(word in region_lower for word in message_lower.split() if len(word) > 3):
mentioned_regions.append(region)
# Специальные случаи
elif 'чукот' in message_lower and 'чукот' in region_lower:
mentioned_regions.append(region)
elif 'питер' in message_lower and ('санкт-петербург' in region_lower or 'ленинград' in region_lower):
mentioned_regions.append(region)
elif 'москв' in message_lower and 'москв' in region_lower:
mentioned_regions.append(region)
# Если регион упомянут - даём полную статистику
context_hotels = []
if mentioned_regions:
for region in mentioned_regions[:3]: # Макс 3 региона
cur.execute("""
SELECT m.full_name, m.region_name,
r.main_data->>'websiteAddress' as website,
a.total_score, a.has_website
FROM hotel_main m
LEFT JOIN hotel_raw_json r ON m.id = r.hotel_id
LEFT JOIN hotel_audit_results a ON m.id = a.hotel_id
WHERE m.region_name = %s
ORDER BY a.total_score DESC NULLS LAST
LIMIT 50;
""", (region,))
context_hotels.extend(cur.fetchall())
else:
# Общий поиск
cur.execute("""
SELECT m.full_name, m.region_name,
r.main_data->>'websiteAddress' as website,
a.total_score, a.has_website
FROM hotel_main m
LEFT JOIN hotel_raw_json r ON m.id = r.hotel_id
LEFT JOIN hotel_audit_results a ON m.id = a.hotel_id
WHERE m.full_name ILIKE %s
LIMIT 10;
""", (f'%{message.message}%',))
context_hotels = cur.fetchall()
# Статистика
cur.execute("SELECT count(*) as total FROM hotel_main;")
total = cur.fetchone()['total']
# Получаем статистику по регионам для контекста
cur.execute("""
SELECT m.region_name, count(*) as count,
count(r.hotel_id) as processed,
count(a.hotel_id) as audited
FROM hotel_main m
LEFT JOIN hotel_raw_json r ON m.id = r.hotel_id
LEFT JOIN hotel_audit_results a ON m.id = a.hotel_id
WHERE m.region_name ILIKE %s
GROUP BY m.region_name
LIMIT 5;
""", (f'%{message.message}%',))
region_stats = cur.fetchall()
cur.close()
conn.close()
# Формируем детальный контекст
context = f"""Ты - ассистент для анализа данных отелей России.
БАЗА ДАННЫХ:
- Всего отелей в РФ: {total}
- Обработано детально: ~31,000
- Регионов: 85
{memory_context}
{semantic_context}"""
# Детальная статистика по упомянутым регионам
if mentioned_regions:
context += "\nСТАТИСТИКА ПО УПОМЯНУТЫМ РЕГИОНАМ:\n"
for region in mentioned_regions:
with_sites = sum(1 for h in context_hotels if h['region_name'] == region and h['has_website'])
without_sites = sum(1 for h in context_hotels if h['region_name'] == region and not h['has_website'])
total_reg = with_sites + without_sites
if total_reg > 0:
context += f"\n{region}:\n"
context += f"- Всего отелей: {total_reg}\n"
context += f"- С сайтами: {with_sites} ({with_sites/total_reg*100:.1f}%)\n"
context += f"- БЕЗ сайтов: {without_sites} ({without_sites/total_reg*100:.1f}%)\n"
if context_hotels:
context += "\nОТЕЛИ (детально):\n"
for i, h in enumerate(context_hotels[:30]): # Ограничиваем для промпта
context += f"\n{i+1}. {h['full_name']}\n"
context += f" Регион: {h['region_name']}\n"
if h['has_website']:
context += f" Сайт: {h['website'] or 'указан'}\n"
if h['total_score'] is not None:
context += f" Балл аудита: {h['total_score']}/18\n"
else:
context += f" Сайт: НЕТ (балл аудита: 0/18 автоматически)\n"
context += """
ВАЖНО:
- Если у отеля НЕТ сайта - автоматически 0/18 баллов по всем критериям
- Отвечай точно на основе предоставленных данных
- Если спрашивают про конкретный регион - используй статистику выше
"""
# Запрос к LLM через универсальный клиент
messages = [
{"role": "system", "content": context},
{"role": "user", "content": message.message}
]
result = llm.chat_completion(messages)
response_text = result['text']
# Сохраняем диалог в память агента
try:
conversation_content = f"Пользователь: {message.message}\nАссистент: {response_text}"
mcp_memory_add_memory(
name=f"Chat with {user_id}",
episode_body=conversation_content,
group_id=user_id,
source="chat",
source_description=f"Chat conversation with user {user_id}"
)
except Exception as e:
print(f"Ошибка сохранения в память: {e}")
return {"response": response_text}
except Exception as e:
return {"response": f"Ошибка: {str(e)}"}
@app.get("/api/criteria")
async def get_criteria():
"""Список критериев аудита"""
from audit_system import AUDIT_CRITERIA
return AUDIT_CRITERIA
@app.post("/api/audit/run")
async def run_audit(request: dict):
"""Запустить аудит региона в фоне"""
region = request.get('region')
force_recrawl = request.get('force_recrawl', False)
force_audit = request.get('force_audit', False)
# Проверяем есть ли уже спарсенные данные
try:
cur = get_db_connection().cursor()
# Считаем отели с сайтами и обработанные
cur.execute('''
SELECT
COUNT(CASE WHEN h.website_address IS NOT NULL AND h.website_address != '' AND h.website_address != '-' THEN 1 END) as with_websites,
COUNT(DISTINCT w.hotel_id) as crawled_count
FROM hotel_main h
LEFT JOIN hotel_website_raw w ON h.id = w.hotel_id
WHERE h.region_name ILIKE %s
''', (f'%{region}%',))
result = cur.fetchone()
with_websites = result['with_websites'] if result else 0
crawled_count = result['crawled_count'] if result else 0
cur.close()
# Определяем что делать
if crawled_count == 0 and with_websites > 0:
# Нет данных - запускаем краулер
import subprocess
subprocess.Popen([
'python', 'universal_crawler.py', region
], cwd='/root/engine/public_oversight/hotels',
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return {
"status": "crawler_started",
"region": region,
"hotels_to_crawl": with_websites,
"message": f"🚀 Запущен краулинг {with_websites} отелей. Примерное время: {with_websites * 7 / 60:.1f} минут. После завершения запустите аудит повторно."
}
elif crawled_count > 0 and crawled_count < with_websites and not force_recrawl and not force_audit:
# Частично обработано - спрашиваем что делать
return {
"status": "partial_data",
"region": region,
"crawled": crawled_count,
"total": with_websites,
"message": f"⚠️ Обработано {crawled_count} из {with_websites} отелей. Добавьте 'force_audit': true для запуска аудита с частичными данными."
}
elif crawled_count >= with_websites or force_recrawl or force_audit:
# Данные есть - запускаем аудит
import subprocess
subprocess.Popen([
'python', 'audit_system.py', region, f"hotel_{region.replace(' ', '_').lower()}"
], cwd='/root/engine/public_oversight/hotels')
return {
"status": "audit_started",
"region": region,
"hotels_count": crawled_count,
"message": f"✅ Аудит запущен для {crawled_count} отелей"
}
else:
return {
"status": "no_websites",
"region": region,
"message": f"В регионе нет отелей с указанными сайтами"
}
except Exception as e:
return {"status": "error", "message": str(e)}
@app.get("/api/audit/download/{region}")
async def download_audit(region: str):
"""Скачать Excel отчет по аудиту"""
import os
from fastapi.responses import FileResponse
# Ищем последний файл аудита для региона
region_safe = region.replace(' ', '_')
audit_dir = '/root/engine/public_oversight/hotels'
try:
files = [f for f in os.listdir(audit_dir) if f.startswith(f'audit_{region_safe}') and f.endswith('.xlsx')]
if not files:
return {"error": f"Файл аудита для региона '{region}' не найден. Сначала запустите аудит."}
# Берем последний файл (по дате в имени)
files.sort(reverse=True)
latest_file = files[0]
file_path = os.path.join(audit_dir, latest_file)
return FileResponse(
path=file_path,
filename=latest_file,
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
except Exception as e:
return {"error": str(e)}
@app.post("/api/audit/criteria-stats")
async def get_criteria_stats(request: dict):
"""Статистика по критериям аудита для региона"""
conn = get_db_connection()
cur = conn.cursor()
try:
region = request.get('region', '')
if not region:
return {"status": "error", "error": "Параметр region обязателен"}
# Получаем все результаты аудита для региона
audit_query = """
SELECT criteria_results
FROM hotel_audit_results
WHERE region_name ILIKE %s
"""
cur.execute(audit_query, (f'%{region}%',))
audit_results = cur.fetchall()
if not audit_results:
return {"status": "error", "error": "Нет данных аудита для региона"}
# Собираем статистику по критериям
criteria_stats = {}
for row in audit_results:
criteria_results = row['criteria_results']
if criteria_results:
for criterion_key, criterion_data in criteria_results.items():
# Получаем название критерия и результат
criterion_name = criterion_data.get('name', criterion_key)
verdict = criterion_data.get('verdict', 'НЕТ')
if criterion_name not in criteria_stats:
criteria_stats[criterion_name] = {
'criterion_name': criterion_name,
'total_count': 0,
'yes_count': 0,
'partial_count': 0,
'no_count': 0
}
criteria_stats[criterion_name]['total_count'] += 1
if verdict == 'ДА':
criteria_stats[criterion_name]['yes_count'] += 1
elif verdict == 'ЧАСТИЧНО':
criteria_stats[criterion_name]['partial_count'] += 1
else: # НЕТ
criteria_stats[criterion_name]['no_count'] += 1
# Конвертируем в список и добавляем проценты
criteria_list = []
for criterion_data in criteria_stats.values():
if criterion_data['total_count'] > 0:
yes_percentage = (criterion_data['yes_count'] / criterion_data['total_count']) * 100
partial_percentage = (criterion_data['partial_count'] / criterion_data['total_count']) * 100
no_percentage = (criterion_data['no_count'] / criterion_data['total_count']) * 100
criteria_list.append({
'criterion_name': criterion_data['criterion_name'],
'total_count': criterion_data['total_count'],
'yes_count': criterion_data['yes_count'],
'partial_count': criterion_data['partial_count'],
'no_count': criterion_data['no_count'],
'yes_percentage': round(yes_percentage, 1),
'partial_percentage': round(partial_percentage, 1),
'no_percentage': round(no_percentage, 1)
})
# Сортируем по проценту "ДА"
criteria_list.sort(key=lambda x: x['yes_percentage'], reverse=True)
cur.close()
conn.close()
return {
"status": "success",
"region": region,
"criteria_stats": criteria_list
}
except Exception as e:
cur.close()
conn.close()
return {"status": "error", "error": str(e)}
@app.get("/api/llm/info")
async def get_llm_info():
"""Информация о текущей LLM модели"""
return get_model_info()
@app.get("/api/llm/models")
async def get_llm_models():
"""Список доступных моделей"""
return {
"current_provider": llm.provider,
"models": get_available_models(),
"current_model": llm.model
}
@app.post("/api/llm/switch")
async def switch_llm_model(request: dict):
"""Сменить модель на лету"""
model = request.get('model')
if not model:
raise HTTPException(400, "model parameter required")
# Сохраняем модель в переменной окружения
os.environ['LLM_MODEL'] = model
# Меняем модель
llm.model = model
return {
"status": "switched",
"new_model": model,
"provider": llm.provider,
"provider_config": llm.provider_config
}
@app.post("/api/llm/switch-provider")
async def switch_provider(request: dict):
"""Переключить провайдера (OpenAI/OpenRouter/Ollama)"""
provider = request.get('provider')
if provider not in ['openai', 'openrouter', 'ollama']:
raise HTTPException(400, "Invalid provider")
# Сохраняем в переменную окружения
os.environ['ACTIVE_PROVIDER'] = provider
# Обновляем глобальную переменную
import llm_config
llm_config.ACTIVE_PROVIDER = provider
# Перезагружаем клиент
from llm_client import LLMClient
global llm
llm = LLMClient()
return {
"status": "provider_switched",
"new_provider": provider,
"current_model": llm.model,
"provider_config": llm.provider_config
}
# ===== ЭНДПОИНТЫ ДЛЯ ПАМЯТИ АГЕНТА =====
@app.get("/api/memory/status")
async def memory_status():
"""Статус MCP сервера памяти"""
try:
# Тестируем через MCP инструменты
return {"status": "success", "message": "MCP сервер памяти доступен"}
except Exception as e:
return {"status": "error", "error": str(e)}
@app.get("/api/memory/history/{user_id}")
async def get_memory_history(user_id: str, last_n: int = 10):
"""Получить историю пользователя из памяти"""
try:
# Используем MCP инструменты напрямую
result = mcp_memory_get_episodes(group_id=user_id, last_n=last_n)
return {"status": "success", "data": result}
except Exception as e:
return {"status": "error", "error": str(e)}
@app.post("/api/memory/search")
async def search_memory(data: dict):
"""Поиск в памяти агента"""
try:
user_id = data.get("user_id")
query = data.get("query", "")
max_results = data.get("max_results", 10)
if not user_id:
return {"status": "error", "message": "user_id обязателен"}
# Используем MCP инструменты напрямую
result = mcp_memory_search_memory_facts(query=query, group_ids=[user_id], max_facts=max_results)
return {"status": "success", "data": result}
except Exception as e:
return {"status": "error", "error": str(e)}
@app.post("/api/memory/add")
async def add_memory(request: Request, data: dict):
"""Добавить память в агента"""
try:
user_id = memory_agent.get_user_id(request)
content = data.get("content", "")
source = data.get("source", "chat")
metadata = data.get("metadata", {})
if not content:
return {"status": "error", "message": "content обязателен"}
# Используем MCP инструменты напрямую
result = mcp_memory_add_memory(
name=f"Chat with {user_id}",
episode_body=content,
group_id=user_id,
source=source,
source_description=f"Chat conversation with user {user_id}"
)
return {"status": "success", "data": result}
except Exception as e:
return {"status": "error", "error": str(e)}
@app.get("/api/llm/providers")
async def get_providers():
"""Получить список провайдеров из БД"""
try:
providers = user_settings_manager.get_providers()
# Добавляем информацию о ключах API
provider_info = {
'openai': {'has_key': bool(OPENAI_CONFIG.get('api_key')), 'api_base': OPENAI_CONFIG['api_base']},
'openrouter': {'has_key': bool(OPENROUTER_CONFIG.get('api_key')), 'api_base': OPENROUTER_CONFIG['api_base']},
'ollama': {'has_key': False, 'api_base': OLLAMA_CONFIG['api_base']}
}
available = []
for provider in providers:
info = provider_info.get(provider['provider'], {'has_key': False, 'api_base': ''})
available.append({
"id": provider['provider'],
"name": provider['provider'].upper(),
"description": f"{provider['model_count']} моделей",
"api_base": info['api_base'],
"has_key": info['has_key'],
"model_count": provider['model_count']
})
return {
"current": ACTIVE_PROVIDER,
"available": available
}
except Exception as e:
return {"error": str(e)}
@app.get("/api/llm/models-dynamic")
async def get_dynamic_models():
"""Получить все модели от всех провайдеров из БД"""
try:
all_models = user_settings_manager.get_available_models()
# Группируем по провайдерам
providers = {}
for model in all_models:
provider = model['provider']
if provider not in providers:
providers[provider] = []
providers[provider].append(model)
return {
"status": "success",
"providers": providers,
"total_models": len(all_models)
}
except Exception as e:
return {
"status": "error",
"error": str(e),
"providers": {},
"total_models": 0
}
@app.post("/api/llm/save-user-settings")
async def save_user_settings(request: Request, data: dict):
"""Сохранить настройки пользователя в БД"""
try:
user_id = memory_agent.get_user_id(request)
provider = data.get('provider')
model = data.get('model')
if not provider or not model:
return {"status": "error", "message": "provider и model обязательны"}
# Сохраняем в БД
success = user_settings_manager.set_user_llm_settings(user_id, provider, model)
if success:
# Обновляем глобальные настройки
os.environ['ACTIVE_PROVIDER'] = provider
os.environ['LLM_MODEL'] = model
# Перезагружаем клиент
from llm_client import LLMClient
global llm
llm = LLMClient()
return {
"status": "success",
"message": "Настройки сохранены в БД",
"user_id": user_id,
"provider": provider,
"model": model
}
else:
return {"status": "error", "message": "Ошибка сохранения в БД"}
except Exception as e:
return {"status": "error", "error": str(e)}
@app.get("/api/llm/user-settings")
async def get_user_settings(request: Request):
"""Получить настройки пользователя из БД"""
try:
user_id = memory_agent.get_user_id(request)
settings = user_settings_manager.get_user_llm_settings(user_id)
return {
"status": "success",
"user_id": user_id,
"settings": settings
}
except Exception as e:
return {"status": "error", "error": str(e)}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8888)