Files
hotels/hybrid_audit_spb.py
Фёдор 684fada337 🚀 Full project sync: Hotels RAG & Audit System
 Major Features:
- Complete RAG system for hotel website analysis
- Hybrid audit with BGE-M3 embeddings + Natasha NER
- Universal horizontal Excel reports with dashboards
- Multi-region processing (SPb, Orel, Chukotka, Kamchatka)

📊 Completed Regions:
- Орловская область: 100% (36/36)
- Чукотский АО: 100% (4/4)
- г. Санкт-Петербург: 93% (893/960)
- Камчатский край: 87% (89/102)

🔧 Infrastructure:
- PostgreSQL with pgvector extension
- BGE-M3 embeddings API
- Browserless for web scraping
- N8N workflows for automation
- S3/Nextcloud file storage

📝 Documentation:
- Complete DB schemas
- API documentation
- Setup guides
- Status reports
2025-10-27 22:49:42 +03:00

580 lines
24 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

#!/usr/bin/env python3
"""
ГИБРИДНЫЙ АУДИТ ОТЕЛЕЙ САНКТ-ПЕТЕРБУРГА
Комбинирует 3 подхода:
1. Семантический поиск (BGE-M3 embeddings)
2. Регулярные выражения (точные паттерны)
3. NER с Natasha (извлечение сущностей)
"""
import psycopg2
from psycopg2.extras import RealDictCursor
import requests
import json
import pandas as pd
from datetime import datetime
import time
from urllib.parse import unquote
import re
# Natasha для NER
from natasha import (
Segmenter,
MorphVocab,
NewsEmbedding,
NewsMorphTagger,
NewsSyntaxParser,
NewsNERTagger,
Doc
)
# Конфигурация БД
DB_CONFIG = {
'host': "147.45.189.234",
'port': 5432,
'database': "default_db",
'user': "gen_user",
'password': unquote("2~~9_%5EkVsU%3F2%5CS")
}
# Конфигурация для BGE-M3 API
BGE_API_URL = "http://147.45.146.17:8002/embed"
BGE_API_KEY = "22564b177aa73b6ac0b8642d7773350ff4c01d4983f028beff15ea247f09fa89"
# Инициализация Natasha
print("🔧 Инициализация Natasha...")
segmenter = Segmenter()
morph_vocab = MorphVocab()
emb = NewsEmbedding()
morph_tagger = NewsMorphTagger(emb)
syntax_parser = NewsSyntaxParser(emb)
ner_tagger = NewsNERTagger(emb)
print("✅ Natasha готова!")
# 18 НАСТОЯЩИХ критериев аудита с регулярками
AUDIT_CRITERIA = [
{
'id': 1,
'name': 'Юридическая идентификация и верификация',
'query': 'полное наименование организации ОПФ ИНН ОГРН ЕГРЮЛ ЕГРИП проверить',
'keywords': ['инн', 'огрн', 'егрюл', 'егрип', 'организация', 'ооо', 'ип'],
'required_patterns': [
r'\b\d{10}\b', # ИНН юридического лица (10 цифр)
r'\b\d{12}\b', # ИНН ИП (12 цифр)
r'\b\d{13}\b', # ОГРН (13 цифр)
r'\b\d{15}\b', # ОГРНИП (15 цифр)
r'инн\s*:?\s*\d{10,12}',
r'огрн\s*:?\s*\d{13}',
r'огрнип\s*:?\s*\d{15}',
],
'use_ner': True, # Использовать Natasha для извлечения названий организаций
'weight': 1.0
},
{
'id': 2,
'name': 'Адрес',
'query': 'юридический адрес фактический адрес местонахождение',
'keywords': ['адрес', 'address', 'местонахождение', 'г.', 'ул.'],
'priority_patterns': [
r'\d{6}.*?ул\.', # Индекс + ул.
r'ул\.\s*[А-Яа-яёЁA-Za-z\s]+,?\s*\d+', # ул. Название, дом
],
'use_ner': True, # Использовать Natasha для извлечения адресов
'weight': 1.0
},
{
'id': 3,
'name': 'Контакты',
'query': 'телефон email форма обратной связи чат контакты',
'keywords': ['телефон', 'phone', 'email', '@', '+7', '8-800'],
'priority_patterns': [
r'(?:\+7|8)\s*\(?\d{3,5}\)?\s*\d{1,3}[-\s]?\d{2}[-\s]?\d{2}', # Телефон
r'[\w\.-]+@[\w\.-]+\.\w{2,}', # Email
],
'weight': 1.0
},
{
'id': 4,
'name': 'Режим работы',
'query': 'часы работы график приема режим работы колл-центр',
'keywords': ['часы работы', 'график работы', 'режим работы', 'круглосуточно', '24/7'],
'priority_patterns': [
r'(?:с|с\s+)\d{1,2}(?::|\.)\d{2}\s*(?:до|по)\s*\d{1,2}(?::|\.)\d{2}', # с 9:00 до 18:00
r'\d{1,2}:\d{2}\s*-\s*\d{1,2}:\d{2}', # 9:00 - 18:00
r'круглосуточно',
r'24\s*[/\-]\s*7',
],
'weight': 1.0
},
{
'id': 5,
'name': 'Политика ПДн (152-ФЗ)',
'query': 'политика персональных данных обработка ПДн 152-ФЗ',
'keywords': ['персональных данных', 'пдн', '152-фз', 'privacy'],
'priority_patterns': [
r'политика\s+в\s+отношении\s+обработки\s+персональных\s+данных',
r'152[-\s]?фз',
r'федеральный\s+закон.*?персональных\s+данных',
],
'weight': 1.0
},
{
'id': 6,
'name': 'Роскомнадзор (реестр)',
'query': 'роскомнадзор реестр операторов персональных данных',
'keywords': ['роскомнадзор', 'реестр', 'оператор'],
'weight': 1.0
},
{
'id': 7,
'name': 'Договор-оферта / Правила оказания услуг',
'query': 'договор оферта правила оказания услуг условия',
'keywords': ['договор', 'оферта', 'правила', 'условия', 'услуг'],
'priority_patterns': [
r'публичная\s+оферта',
r'договор.*?оказани.*?услуг',
],
'weight': 1.0
},
{
'id': 8,
'name': 'Рекламации и споры',
'query': 'рекламации споры жалобы претензии решение конфликтов',
'keywords': ['рекламация', 'спор', 'жалоба', 'претензия', 'конфликт'],
'weight': 1.0
},
{
'id': 9,
'name': 'Цены/прайс',
'query': 'цены прайс тарифы стоимость номера',
'keywords': ['цена', 'прайс', 'тариф', 'стоимость', 'номер'],
'priority_patterns': [
r'\d+\s*(?:руб|₽)', # Цены в рублях
],
'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 generate_embedding(text: str) -> list:
"""Генерация эмбеддинга для текста через API"""
headers = {
"X-API-Key": BGE_API_KEY,
"Content-Type": "application/json"
}
payload = {"text": [text]}
response = requests.post(BGE_API_URL, json=payload, headers=headers, timeout=30)
response.raise_for_status()
return response.json().get('embeddings', [[]])[0]
def semantic_search_for_criterion(hotel_id: str, query: str, limit: int = 3):
"""Семантический поиск по chunks отеля"""
try:
query_embedding = generate_embedding(query)
embedding_str = json.dumps(query_embedding)
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
query_sql = f"""
SELECT
text,
metadata->>'url' as url,
embedding <-> %s::vector as distance
FROM hotel_website_chunks
WHERE metadata->>'hotel_id' = %s AND embedding IS NOT NULL
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"Ошибка семантического поиска: {e}")
return []
def check_patterns(text: str, patterns: list) -> dict:
"""Проверка текста на соответствие регулярным выражениям"""
matches = []
for pattern in patterns:
found = re.findall(pattern, text, re.IGNORECASE)
if found:
matches.extend(found[:3]) # Макс 3 совпадения на паттерн
return {
'found': len(matches) > 0,
'matches': matches[:5], # Макс 5 совпадений всего
'count': len(matches)
}
def extract_entities_with_natasha(text: str) -> dict:
"""Извлечение сущностей с помощью Natasha"""
try:
doc = Doc(text[:5000]) # Ограничиваем длину для производительности
doc.segment(segmenter)
doc.tag_morph(morph_tagger)
doc.parse_syntax(syntax_parser)
doc.tag_ner(ner_tagger)
entities = {
'ORG': [], # Организации
'PER': [], # Люди
'LOC': [], # Локации/адреса
}
for span in doc.spans:
if span.type in entities:
entities[span.type].append(span.text)
return entities
except Exception as e:
print(f"Ошибка Natasha: {e}")
return {'ORG': [], 'PER': [], 'LOC': []}
def hybrid_audit_criterion(hotel_id: str, criterion: dict) -> dict:
"""
Гибридный аудит по одному критерию:
1. Семантический поиск
2. Проверка регулярками
3. NER с Natasha (если включено)
"""
result = {
'semantic_score': 0.0,
'pattern_score': 0.0,
'ner_score': 0.0,
'final_score': 0.0,
'evidence': [],
'explanation': '',
'approval_urls': [], # Ссылки на страницы
'approval_quotes': [] # Цитаты с контекстом
}
# 1. СЕМАНТИЧЕСКИЙ ПОИСК
semantic_matches = semantic_search_for_criterion(hotel_id, criterion['query'], limit=3)
if semantic_matches:
best_match = semantic_matches[0]
distance = best_match['distance']
url = best_match.get('url', 'Нет URL')
if distance < 0.7:
result['semantic_score'] = 1.0
result['evidence'].append(f"🔍 Семантика (отлично, {distance:.3f})")
result['approval_urls'].append(url)
result['approval_quotes'].append({
'url': url,
'quote': best_match['text'][:300],
'method': 'Семантический поиск',
'distance': f"{distance:.3f}"
})
elif distance < 0.9:
result['semantic_score'] = 0.5
result['evidence'].append(f"🔍 Семантика (средне, {distance:.3f})")
result['approval_urls'].append(url)
result['approval_quotes'].append({
'url': url,
'quote': best_match['text'][:300],
'method': 'Семантический поиск',
'distance': f"{distance:.3f}"
})
else:
result['semantic_score'] = 0.2
result['evidence'].append(f"🔍 Семантика (слабо, {distance:.3f})")
result['approval_urls'].append(url)
result['approval_quotes'].append({
'url': url,
'quote': best_match['text'][:300],
'method': 'Семантический поиск',
'distance': f"{distance:.3f}"
})
# 2. ПРОВЕРКА РЕГУЛЯРКАМИ
if 'priority_patterns' in criterion or 'required_patterns' in criterion:
patterns = criterion.get('priority_patterns', []) + criterion.get('required_patterns', [])
# Проверяем все найденные семантикой тексты
for match in semantic_matches:
pattern_check = check_patterns(match['text'], patterns)
if pattern_check['found']:
result['pattern_score'] = 1.0
result['evidence'].append(f"✅ Регулярки: найдено {pattern_check['count']} совпадений")
# Добавляем цитату с найденными паттернами
url = match.get('url', 'Нет URL')
if url not in result['approval_urls']:
result['approval_urls'].append(url)
result['approval_quotes'].append({
'url': url,
'quote': match['text'][:300],
'method': 'Регулярные выражения',
'matches': ', '.join(pattern_check['matches'])
})
break # Нашли - хватит
else:
result['pattern_score'] = 0.0
# 3. NER С NATASHA
if criterion.get('use_ner', False) and semantic_matches:
all_text = " ".join([m['text'] for m in semantic_matches])
entities = extract_entities_with_natasha(all_text)
if criterion['id'] == 1: # Юридическая идентификация
if entities['ORG']:
result['ner_score'] = 1.0
result['evidence'].append(f"🏢 Natasha (организации): {', '.join(entities['ORG'][:3])}")
# Добавляем цитату с найденными организациями
url = semantic_matches[0].get('url', 'Нет URL')
if url not in result['approval_urls']:
result['approval_urls'].append(url)
result['approval_quotes'].append({
'url': url,
'quote': all_text[:300],
'method': 'Natasha NER (организации)',
'entities': ', '.join(entities['ORG'][:3])
})
else:
result['ner_score'] = 0.0
elif criterion['id'] == 2: # Адрес
if entities['LOC']:
result['ner_score'] = 1.0
result['evidence'].append(f"📍 Natasha (адреса): {', '.join(entities['LOC'][:3])}")
# Добавляем цитату с найденными адресами
url = semantic_matches[0].get('url', 'Нет URL')
if url not in result['approval_urls']:
result['approval_urls'].append(url)
result['approval_quotes'].append({
'url': url,
'quote': all_text[:300],
'method': 'Natasha NER (адреса)',
'entities': ', '.join(entities['LOC'][:3])
})
else:
result['ner_score'] = 0.0
# ИТОГОВАЯ ОЦЕНКА (взвешенная)
weights = {
'semantic': 0.4,
'pattern': 0.4,
'ner': 0.2
}
result['final_score'] = (
result['semantic_score'] * weights['semantic'] +
result['pattern_score'] * weights['pattern'] +
result['ner_score'] * weights['ner']
)
# Объяснение
if result['final_score'] >= 0.8:
result['explanation'] = "🟢 Высокая: информация найдена и подтверждена"
elif result['final_score'] >= 0.5:
result['explanation'] = "🟡 Средняя: информация найдена частично"
elif result['final_score'] >= 0.3:
result['explanation'] = "🟠 Низкая: информация найдена, но не подтверждена"
else:
result['explanation'] = "🔴 Очень низкая: информация не найдена"
return result
def audit_hotel_hybrid(hotel_info: dict):
"""Проводит гибридный аудит для одного отеля"""
hotel_id = hotel_info['id']
hotel_name = hotel_info['full_name']
region_name = hotel_info['region_name']
print(f"\n🏨 ГИБРИДНЫЙ АУДИТ: {hotel_name}")
print("=" * 80)
results = {
'hotel_id': hotel_id,
'hotel_name': hotel_name,
'region_name': region_name,
'total_score': 0.0,
'criteria_results': {}
}
for criterion in AUDIT_CRITERIA:
print(f" 🔍 Критерий {criterion['id']}: {criterion['name']}")
audit_result = hybrid_audit_criterion(hotel_id, criterion)
results['criteria_results'][criterion['name']] = audit_result
results['total_score'] += audit_result['final_score']
print(f" {audit_result['explanation']} (Итого: {audit_result['final_score']:.2f}/1.0)")
print(f" └─ Семантика: {audit_result['semantic_score']:.2f} | Регулярки: {audit_result['pattern_score']:.2f} | NER: {audit_result['ner_score']:.2f}")
for evidence in audit_result['evidence'][:2]: # Показываем первые 2 доказательства
print(f" {evidence}")
time.sleep(0.5) # Небольшая пауза между критериями
print(f"\n📊 ИТОГОВАЯ ОЦЕНКА: {results['total_score']:.2f}/{len(AUDIT_CRITERIA)} ({results['total_score']/len(AUDIT_CRITERIA)*100:.1f}%)")
print("=" * 80)
return results
def main():
print("🚀 ГИБРИДНЫЙ АУДИТ ОТЕЛЕЙ ЧУКОТКИ")
print("=" * 80)
print("Методы:")
print(" 1⃣ Семантический поиск (BGE-M3)")
print(" 2⃣ Регулярные выражения")
print(" 3⃣ NER с Natasha")
print("=" * 80)
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
# Получаем ВСЕ отели Чукотского автономного округа с эмбеддингами
cur.execute("""
SELECT DISTINCT ON (hm.id) hm.id, hm.full_name, hm.region_name
FROM hotel_main hm
JOIN hotel_website_chunks hwc ON hm.id::text = hwc.metadata->>'hotel_id'
WHERE hm.region_name = 'г. Санкт-Петербург';
""")
chukotka_hotels = cur.fetchall()
cur.close()
conn.close()
if not chukotka_hotels:
print("Не найдено отелей в Чукотском автономном округе с эмбеддингами.")
return
print(f"\n📊 Найдено {len(chukotka_hotels)} отелей для гибридного аудита:")
for hotel in chukotka_hotels:
print(f"{hotel['full_name']}")
print()
all_audit_results = []
for hotel_info in chukotka_hotels:
audit_results = audit_hotel_hybrid(hotel_info)
all_audit_results.append(audit_results)
# Создание Excel отчета
df_data = []
for hotel_result in all_audit_results:
row = {
'ID Отеля': hotel_result['hotel_id'],
'Название Отеля': hotel_result['hotel_name'],
'Регион': hotel_result['region_name'],
'Общий балл': f"{hotel_result['total_score']:.2f}/{len(AUDIT_CRITERIA)}"
}
for criterion_name, crit_data in hotel_result['criteria_results'].items():
row[f'{criterion_name} (Итого)'] = f"{crit_data['final_score']:.2f}"
row[f'{criterion_name} (Семантика)'] = f"{crit_data['semantic_score']:.2f}"
row[f'{criterion_name} (Регулярки)'] = f"{crit_data['pattern_score']:.2f}"
row[f'{criterion_name} (NER)'] = f"{crit_data['ner_score']:.2f}"
row[f'{criterion_name} (Объяснение)'] = crit_data['explanation']
row[f'{criterion_name} (Доказательства)'] = "\n".join(crit_data['evidence'])
# ДОБАВЛЯЕМ URL И ЦИТАТЫ!
if crit_data.get('approval_urls'):
row[f'{criterion_name} (URL)'] = "\n".join(crit_data['approval_urls'])
else:
row[f'{criterion_name} (URL)'] = "Не найдено"
if crit_data.get('approval_quotes'):
quotes_text = []
for quote_data in crit_data['approval_quotes']:
quote_str = f"[{quote_data['method']}]\n"
quote_str += f"URL: {quote_data['url']}\n"
quote_str += f"Цитата: {quote_data['quote']}\n"
if 'matches' in quote_data:
quote_str += f"Найдено: {quote_data['matches']}\n"
if 'entities' in quote_data:
quote_str += f"Сущности: {quote_data['entities']}\n"
if 'distance' in quote_data:
quote_str += f"Distance: {quote_data['distance']}\n"
quotes_text.append(quote_str)
row[f'{criterion_name} (Цитаты)'] = "\n---\n".join(quotes_text)
else:
row[f'{criterion_name} (Цитаты)'] = "Не найдено"
df_data.append(row)
df = pd.DataFrame(df_data)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_filename = f"hybrid_audit_chukotka_{timestamp}.xlsx"
df.to_excel(output_filename, index=False)
print(f"\n✅ Гибридный отчет сохранен в {output_filename}")
if __name__ == "__main__":
main()