390 lines
15 KiB
Python
390 lines
15 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
Семантический аудит отелей Чукотки с использованием эмбеддингов
|
|||
|
|
18 критериев аудита
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import psycopg2
|
|||
|
|
from psycopg2.extras import RealDictCursor
|
|||
|
|
from urllib.parse import unquote
|
|||
|
|
import requests
|
|||
|
|
import json
|
|||
|
|
import pandas as pd
|
|||
|
|
from datetime import datetime
|
|||
|
|
import time
|
|||
|
|
|
|||
|
|
# Конфигурация
|
|||
|
|
DB_CONFIG = {
|
|||
|
|
'host': "147.45.189.234",
|
|||
|
|
'port': 5432,
|
|||
|
|
'database': "default_db",
|
|||
|
|
'user': "gen_user",
|
|||
|
|
'password': unquote("2~~9_%5EkVsU%3F2%5CS")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
BGE_API_URL = "http://147.45.146.17:8002/embed"
|
|||
|
|
BGE_API_KEY = "22564b177aa73b6ac0b8642d7773350ff4c01d4983f028beff15ea247f09fa89"
|
|||
|
|
|
|||
|
|
# 18 НАСТОЯЩИХ критериев аудита из audit_system_new.py
|
|||
|
|
AUDIT_CRITERIA = [
|
|||
|
|
{
|
|||
|
|
'id': 1,
|
|||
|
|
'name': 'Юридическая идентификация и верификация',
|
|||
|
|
'query': 'полное наименование организации ОПФ ИНН ОГРН ЕГРЮЛ ЕГРИП проверить',
|
|||
|
|
'keywords': ['инн', 'огрн', 'егрюл', 'егрип', 'организация', 'ооо', 'ип'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 2,
|
|||
|
|
'name': 'Адрес',
|
|||
|
|
'query': 'юридический адрес фактический адрес местонахождение',
|
|||
|
|
'keywords': ['адрес', 'address', 'местонахождение', 'г.', 'ул.'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 3,
|
|||
|
|
'name': 'Контакты',
|
|||
|
|
'query': 'телефон email форма обратной связи чат контакты',
|
|||
|
|
'keywords': ['телефон', 'phone', 'email', '@', '+7', '8-800'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 4,
|
|||
|
|
'name': 'Режим работы',
|
|||
|
|
'query': 'часы работы график приема режим работы колл-центр',
|
|||
|
|
'keywords': ['часы работы', 'график работы', 'режим работы', 'круглосуточно', '24/7', 'пн-пт', 'пн-вс', 'время работы'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 5,
|
|||
|
|
'name': 'Политика ПДн (152-ФЗ)',
|
|||
|
|
'query': 'политика персональных данных обработка ПДн 152-ФЗ',
|
|||
|
|
'keywords': ['персональных данных', 'пдн', '152-фз', 'privacy'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 6,
|
|||
|
|
'name': 'Роскомнадзор (реестр)',
|
|||
|
|
'query': 'роскомнадзор реестр операторов персональных данных',
|
|||
|
|
'keywords': ['роскомнадзор', 'реестр', 'оператор'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 7,
|
|||
|
|
'name': 'Договор-оферта / Правила оказания услуг',
|
|||
|
|
'query': 'договор оферта правила оказания услуг условия',
|
|||
|
|
'keywords': ['договор', 'оферта', 'правила', 'условия', 'услуг'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 8,
|
|||
|
|
'name': 'Рекламации и споры',
|
|||
|
|
'query': 'рекламации споры жалобы претензии решение конфликтов',
|
|||
|
|
'keywords': ['рекламация', 'спор', 'жалоба', 'претензия', 'конфликт'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 9,
|
|||
|
|
'name': 'Цены/прайс',
|
|||
|
|
'query': 'цены прайс тарифы стоимость номера',
|
|||
|
|
'keywords': ['цена', 'прайс', 'тариф', 'стоимость', 'номер'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 10,
|
|||
|
|
'name': 'Способы оплаты',
|
|||
|
|
'query': 'способы оплаты платеж банковская карта наличные',
|
|||
|
|
'keywords': ['оплата', 'платеж', 'карта', 'наличные', 'способ'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 11,
|
|||
|
|
'name': 'Онлайн-оплата',
|
|||
|
|
'query': 'онлайн оплата интернет платеж карта через сайт',
|
|||
|
|
'keywords': ['онлайн', 'интернет', 'платеж', 'карта', 'сайт'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 12,
|
|||
|
|
'name': 'Онлайн-бронирование',
|
|||
|
|
'query': 'онлайн бронирование заказ номера через сайт',
|
|||
|
|
'keywords': ['бронирование', 'заказ', 'номер', 'сайт', 'онлайн'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 13,
|
|||
|
|
'name': 'FAQ',
|
|||
|
|
'query': 'часто задаваемые вопросы FAQ помощь',
|
|||
|
|
'keywords': ['faq', 'вопрос', 'ответ', 'помощь', 'часто'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 14,
|
|||
|
|
'name': 'Доступность для ЛОВЗ',
|
|||
|
|
'query': 'доступность инвалиды ЛОВЗ безбарьерная среда',
|
|||
|
|
'keywords': ['доступность', 'инвалид', 'ловз', 'безбарьерная'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 15,
|
|||
|
|
'name': 'Партнёры/бренды',
|
|||
|
|
'query': 'партнеры бренды сотрудничество франшиза',
|
|||
|
|
'keywords': ['партнер', 'бренд', 'сотрудничество', 'франшиза'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 16,
|
|||
|
|
'name': 'Команда/сотрудники',
|
|||
|
|
'query': 'команда сотрудники персонал коллектив',
|
|||
|
|
'keywords': ['команда', 'сотрудник', 'персонал', 'коллектив'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 17,
|
|||
|
|
'name': 'Уголок потребителя',
|
|||
|
|
'query': 'уголок потребителя права потребителя защита прав',
|
|||
|
|
'keywords': ['потребитель', 'права', 'защита', 'уголок'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'id': 18,
|
|||
|
|
'name': 'Актуальность документов',
|
|||
|
|
'query': 'актуальность документов дата обновления свежая информация',
|
|||
|
|
'keywords': ['актуальность', 'документ', 'дата', 'обновление', 'свежая'],
|
|||
|
|
'weight': 1.0
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
def get_db_connection():
|
|||
|
|
"""Получить подключение к БД"""
|
|||
|
|
return psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
|
|||
|
|
|
|||
|
|
def semantic_search_hotel(hotel_id: str, query: str, 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)
|
|||
|
|
|
|||
|
|
# Поиск по конкретному отелю
|
|||
|
|
conn = get_db_connection()
|
|||
|
|
cur = conn.cursor()
|
|||
|
|
|
|||
|
|
query_sql = """
|
|||
|
|
SELECT
|
|||
|
|
metadata->>'hotel_name' as hotel_name,
|
|||
|
|
metadata->>'url' as url,
|
|||
|
|
text,
|
|||
|
|
embedding <-> %s::vector as distance
|
|||
|
|
FROM hotel_website_chunks
|
|||
|
|
WHERE metadata->>'hotel_id' = %s
|
|||
|
|
ORDER BY embedding <-> %s::vector
|
|||
|
|
LIMIT %s;
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
cur.execute(query_sql, [embedding_str, hotel_id, embedding_str, limit])
|
|||
|
|
results = cur.fetchall()
|
|||
|
|
|
|||
|
|
cur.close()
|
|||
|
|
conn.close()
|
|||
|
|
|
|||
|
|
return results
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"Ошибка семантического поиска для отеля {hotel_id}: {e}")
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
def evaluate_criterion(hotel_id: str, hotel_name: str, criterion: dict):
|
|||
|
|
"""Оценка критерия для отеля"""
|
|||
|
|
print(f" 🔍 Критерий {criterion['id']}: {criterion['name']}")
|
|||
|
|
|
|||
|
|
# Семантический поиск
|
|||
|
|
search_results = semantic_search_hotel(hotel_id, criterion['query'], limit=3)
|
|||
|
|
|
|||
|
|
if not search_results:
|
|||
|
|
return {
|
|||
|
|
'criterion_id': criterion['id'],
|
|||
|
|
'criterion_name': criterion['name'],
|
|||
|
|
'score': 0,
|
|||
|
|
'max_score': criterion['weight'],
|
|||
|
|
'found_text': None,
|
|||
|
|
'relevance': 0.0,
|
|||
|
|
'url': None
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Анализируем результаты
|
|||
|
|
best_result = search_results[0]
|
|||
|
|
distance = best_result['distance']
|
|||
|
|
|
|||
|
|
# Конвертируем расстояние в оценку (чем меньше расстояние, тем выше оценка)
|
|||
|
|
if distance < 0.7:
|
|||
|
|
score = criterion['weight'] # Отлично
|
|||
|
|
relevance = "🟢 Высокая"
|
|||
|
|
elif distance < 0.9:
|
|||
|
|
score = criterion['weight'] * 0.7 # Хорошо
|
|||
|
|
relevance = "🟡 Средняя"
|
|||
|
|
elif distance < 1.1:
|
|||
|
|
score = criterion['weight'] * 0.4 # Удовлетворительно
|
|||
|
|
relevance = "🟠 Низкая"
|
|||
|
|
else:
|
|||
|
|
score = 0 # Не найдено
|
|||
|
|
relevance = "🔴 Очень низкая"
|
|||
|
|
|
|||
|
|
# Проверяем наличие ключевых слов в тексте
|
|||
|
|
text_lower = best_result['text'].lower()
|
|||
|
|
keywords_found = [kw for kw in criterion['keywords'] if kw in text_lower]
|
|||
|
|
|
|||
|
|
if keywords_found:
|
|||
|
|
score = min(score + 0.1 * len(keywords_found), criterion['weight'])
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'criterion_id': criterion['id'],
|
|||
|
|
'criterion_name': criterion['name'],
|
|||
|
|
'score': round(score, 2),
|
|||
|
|
'max_score': criterion['weight'],
|
|||
|
|
'found_text': best_result['text'][:200] + "..." if len(best_result['text']) > 200 else best_result['text'],
|
|||
|
|
'relevance': relevance,
|
|||
|
|
'distance': round(distance, 3),
|
|||
|
|
'keywords_found': keywords_found,
|
|||
|
|
'url': best_result['url']
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def audit_hotel(hotel_id: str, hotel_name: str):
|
|||
|
|
"""Аудит одного отеля"""
|
|||
|
|
print(f"\n🏨 АУДИТ ОТЕЛЯ: {hotel_name}")
|
|||
|
|
print("=" * 60)
|
|||
|
|
|
|||
|
|
results = []
|
|||
|
|
total_score = 0
|
|||
|
|
max_total_score = sum(criterion['weight'] for criterion in AUDIT_CRITERIA)
|
|||
|
|
|
|||
|
|
for criterion in AUDIT_CRITERIA:
|
|||
|
|
result = evaluate_criterion(hotel_id, hotel_name, criterion)
|
|||
|
|
results.append(result)
|
|||
|
|
total_score += result['score']
|
|||
|
|
|
|||
|
|
print(f" ✅ {criterion['name']}: {result['score']}/{result['max_score']} {result['relevance']}")
|
|||
|
|
if result['found_text']:
|
|||
|
|
print(f" 📄 {result['found_text']}")
|
|||
|
|
print()
|
|||
|
|
|
|||
|
|
# Небольшая пауза между запросами
|
|||
|
|
time.sleep(0.5)
|
|||
|
|
|
|||
|
|
percentage = (total_score / max_total_score) * 100 if max_total_score > 0 else 0
|
|||
|
|
|
|||
|
|
print(f"📊 ИТОГОВАЯ ОЦЕНКА: {total_score:.2f}/{max_total_score} ({percentage:.1f}%)")
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'hotel_id': hotel_id,
|
|||
|
|
'hotel_name': hotel_name,
|
|||
|
|
'total_score': round(total_score, 2),
|
|||
|
|
'max_score': max_total_score,
|
|||
|
|
'percentage': round(percentage, 1),
|
|||
|
|
'criteria_results': results
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
"""Основная функция"""
|
|||
|
|
print("🚀 СЕМАНТИЧЕСКИЙ АУДИТ ОТЕЛЕЙ ЧУКОТКИ")
|
|||
|
|
print("=" * 50)
|
|||
|
|
|
|||
|
|
# Получаем отели Чукотки с эмбеддингами
|
|||
|
|
conn = get_db_connection()
|
|||
|
|
cur = conn.cursor()
|
|||
|
|
|
|||
|
|
cur.execute("""
|
|||
|
|
SELECT DISTINCT
|
|||
|
|
h.id,
|
|||
|
|
h.full_name,
|
|||
|
|
COUNT(c.id) as chunks_count
|
|||
|
|
FROM hotel_main h
|
|||
|
|
JOIN hotel_website_chunks c ON h.id::text = c.metadata->>'hotel_id'
|
|||
|
|
WHERE h.region_name ILIKE '%чукот%'
|
|||
|
|
AND c.id IS NOT NULL
|
|||
|
|
GROUP BY h.id, h.full_name
|
|||
|
|
HAVING COUNT(c.id) > 0
|
|||
|
|
ORDER BY chunks_count DESC;
|
|||
|
|
""")
|
|||
|
|
|
|||
|
|
hotels = cur.fetchall()
|
|||
|
|
cur.close()
|
|||
|
|
conn.close()
|
|||
|
|
|
|||
|
|
print(f"📊 Найдено {len(hotels)} отелей с эмбеддингами:")
|
|||
|
|
for hotel in hotels:
|
|||
|
|
print(f" • {hotel['full_name']} ({hotel['chunks_count']} chunks)")
|
|||
|
|
|
|||
|
|
# Проводим аудит
|
|||
|
|
audit_results = []
|
|||
|
|
|
|||
|
|
for hotel in hotels:
|
|||
|
|
result = audit_hotel(hotel['id'], hotel['full_name'])
|
|||
|
|
audit_results.append(result)
|
|||
|
|
|
|||
|
|
# Создаем Excel отчет
|
|||
|
|
create_excel_report(audit_results)
|
|||
|
|
|
|||
|
|
print(f"\n✅ АУДИТ ЗАВЕРШЕН! Результаты сохранены в Excel.")
|
|||
|
|
|
|||
|
|
def create_excel_report(audit_results):
|
|||
|
|
"""Создание Excel отчета"""
|
|||
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|||
|
|
filename = f"semantic_audit_chukotka_{timestamp}.xlsx"
|
|||
|
|
|
|||
|
|
with pd.ExcelWriter(filename, engine='openpyxl') as writer:
|
|||
|
|
# Сводная таблица
|
|||
|
|
summary_data = []
|
|||
|
|
for result in audit_results:
|
|||
|
|
summary_data.append({
|
|||
|
|
'Отель': result['hotel_name'],
|
|||
|
|
'Общая оценка': result['total_score'],
|
|||
|
|
'Максимальная оценка': result['max_score'],
|
|||
|
|
'Процент': f"{result['percentage']}%",
|
|||
|
|
'Статус': 'Отлично' if result['percentage'] >= 80 else 'Хорошо' if result['percentage'] >= 60 else 'Удовлетворительно' if result['percentage'] >= 40 else 'Неудовлетворительно'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
summary_df = pd.DataFrame(summary_data)
|
|||
|
|
summary_df.to_excel(writer, sheet_name='Сводка', index=False)
|
|||
|
|
|
|||
|
|
# Детальные результаты по каждому отелю
|
|||
|
|
for result in audit_results:
|
|||
|
|
sheet_name = result['hotel_name'][:30] # Ограничиваем длину имени листа
|
|||
|
|
|
|||
|
|
detailed_data = []
|
|||
|
|
for criterion in result['criteria_results']:
|
|||
|
|
detailed_data.append({
|
|||
|
|
'Критерий': criterion['criterion_name'],
|
|||
|
|
'Оценка': criterion['score'],
|
|||
|
|
'Максимальная оценка': criterion['max_score'],
|
|||
|
|
'Релевантность': criterion['relevance'],
|
|||
|
|
'Расстояние': criterion['distance'],
|
|||
|
|
'Найденный текст': criterion['found_text'],
|
|||
|
|
'Найденные ключевые слова': ', '.join(criterion.get('keywords_found', [])),
|
|||
|
|
'URL': criterion['url']
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
detailed_df = pd.DataFrame(detailed_data)
|
|||
|
|
detailed_df.to_excel(writer, sheet_name=sheet_name, index=False)
|
|||
|
|
|
|||
|
|
print(f"📊 Excel отчет сохранен: {filename}")
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|