2194 lines
92 KiB
Python
2194 lines
92 KiB
Python
|
|
#!/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)
|
|||
|
|
|