- Краулеры: smart_crawler.py, regional_crawler.py - Аудит: audit_orel_to_excel.py, audit_chukotka_to_excel.py - РКН проверка: check_rkn_registry.py, recheck_unclear_rkn.py - Отчёты: create_orel_horizontal_report.py - Обработка: process_all_hotels_embeddings.py - Документация: README.md, DB_SCHEMA_REFERENCE.md
580 lines
24 KiB
Python
580 lines
24 KiB
Python
#!/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()
|
||
|