utils.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. from landing.models import LandingPage
  2. import logging
  3. import requests
  4. import secrets
  5. import core.settings as settings
  6. from pathlib import Path
  7. from django.core.files.storage import FileSystemStorage
  8. from django.utils import timezone
  9. from django.utils.text import slugify
  10. import csv
  11. import io
  12. import re
  13. from typing import Optional
  14. def create_pages_clean():
  15. if not LandingPage.objects.filter(slug='order'):
  16. LandingPage.objects.create(title="Главная", slug="main", content="")
  17. LandingPage.objects.create(title="Заказ онлайн", slug="order", content="")
  18. LandingPage.objects.create(title="FAQ", slug="faq", content="")
  19. LandingPage.objects.create(title="Цены", slug="price", content="")
  20. LandingPage.objects.create(title="Контакты", slug="contact", content="")
  21. if not LandingPage.objects.filter(slug__startswith="clean"):
  22. LandingPage.objects.create(title="Уборка автосалонов", slug="clean-car", content="")
  23. LandingPage.objects.create(title="Уборка фитнес клубов", slug="clean-fit", content="")
  24. LandingPage.objects.create(title="Уборка офисов, ТЦ и БЦ", slug="clean-office", content="")
  25. LandingPage.objects.create(title="Уборка ТСЖ", slug="clean-tcj", content="")
  26. LandingPage.objects.create(title="Уборка прилегающей территории", slug="clean-area", content="")
  27. LandingPage.objects.create(title="Мойка фасадов и остекления", slug="clean-facades", content="")
  28. LandingPage.objects.create(title="Комплексная уборка квартир", slug="clean-apartments", content="")
  29. LandingPage.objects.create(title="Комплексная уборка коттеджей", slug="clean-cottages", content="")
  30. LandingPage.objects.create(title="Генеральная уборка", slug="clean-out", content="")
  31. LandingPage.objects.create(title="Уборка после ремонта", slug="clean-renovation", content="")
  32. def create_pages_clean_admin(modeladmin, request, queryset):
  33. create_pages_clean()
  34. def call_huggingface_chat(user_message: str) -> str:
  35. if not settings.HF_TOKEN:
  36. return "Ошибка конфигурации: не задан HF_TOKEN."
  37. if not settings.HF_MODEL:
  38. return "Ошибка конфигурации: не задан HF_MODEL."
  39. payload = {
  40. "model": settings.HF_MODEL,
  41. "messages": [
  42. {
  43. "role": "user",
  44. "content": user_message,
  45. }
  46. ],
  47. "stream": False,
  48. }
  49. headers = {
  50. "Authorization": f"Bearer {settings.HF_TOKEN}",
  51. "Content-Type": "application/json",
  52. }
  53. try:
  54. response = requests.post(
  55. "https://router.huggingface.co/v1/chat/completions",
  56. headers=headers,
  57. json=payload,
  58. timeout=60,
  59. )
  60. if not response.ok:
  61. return f"Ошибка ShariX AI [{response.status_code}]: {response.text}"
  62. data = response.json()
  63. return data["choices"][0]["message"]["content"].strip()
  64. except requests.RequestException as exc:
  65. return f"Ошибка запроса к ShariX AI: {exc}"
  66. except (KeyError, IndexError, TypeError, ValueError):
  67. return "Ошибка обработки ответа ShariX AI."
  68. def read_prepared_response(filename: str) -> str:
  69. base_dir = Path(__file__).resolve().parent
  70. file_path = base_dir / "data" / "ai_responces" / "transport2" / filename
  71. if not file_path.exists():
  72. #return f"Ошибка: файл {filename} не найден."
  73. return f"Не получается сгенерировать ответ."
  74. try:
  75. return file_path.read_text(encoding="utf-8").strip()
  76. except OSError as exc:
  77. return f"Error: {exc}"
  78. def get_ai_or_file_response(user_message: str) -> str:
  79. normalized_message = user_message.lower()
  80. trigger_map = {
  81. "readme file": "README.txt",
  82. }
  83. for trigger_text, filename in trigger_map.items():
  84. if trigger_text in normalized_message:
  85. return read_prepared_response(filename)
  86. return call_huggingface_chat(user_message)
  87. logger = logging.getLogger(__name__)
  88. UPLOAD_ACK_TEXT = "Файл загружен и обработан. Напиши, что ты хочешь с ним делать дальше..."
  89. def get_ai_demo_upload_dir() -> Path:
  90. return Path(__file__).resolve().parent / "data" / "uploads"
  91. def build_unique_upload_name(original_name: str) -> str:
  92. original_path = Path(original_name)
  93. safe_stem = slugify(original_path.stem, allow_unicode=True) or "file"
  94. timestamp = timezone.now().strftime("%Y%m%d_%H%M%S")
  95. random_part = secrets.token_hex(4)
  96. suffix = original_path.suffix.lower()
  97. return f"{safe_stem}__{timestamp}__{random_part}{suffix}"
  98. def handle_ai_demo_upload(uploaded_file) -> None:
  99. """
  100. Реально сохраняем только файлы, у которых в имени есть 'task'.
  101. Остальные файлы считаем 'успешно загруженными', но не сохраняем.
  102. Ошибки наружу не выбрасываем.
  103. """
  104. original_name = Path(uploaded_file.name).name or "file"
  105. if "task" not in original_name.lower():
  106. return
  107. upload_dir = get_ai_demo_upload_dir()
  108. try:
  109. upload_dir.mkdir(parents=True, exist_ok=True)
  110. storage = FileSystemStorage(location=str(upload_dir))
  111. stored_name = build_unique_upload_name(original_name)
  112. storage.save(stored_name, uploaded_file)
  113. except Exception:
  114. logger.exception("Не удалось сохранить загруженный файл: %s", original_name)
  115. # Наружу ошибку не отдаём специально.
  116. return
  117. create_pages_clean_admin.short_description = "Созадть страницы по умолчанию"
  118. def normalize_faq_text(value: str) -> str:
  119. return re.sub(r"\s+", " ", str(value or "").strip().lower())
  120. def split_faq_keywords(value: str) -> list[str]:
  121. raw_value = str(value or "")
  122. result = []
  123. for item in re.split(r"[,;|]", raw_value):
  124. normalized = normalize_faq_text(item)
  125. if normalized:
  126. result.append(normalized)
  127. return result
  128. def read_faq_csv_rows(csv_file) -> list[dict]:
  129. """
  130. Поддерживаем универсальный CSV формата:
  131. question;answer;keywords
  132. Первая строка может быть заголовком.
  133. Поле keywords необязательно.
  134. """
  135. if not csv_file:
  136. return []
  137. try:
  138. csv_file.open("rb")
  139. raw_content = csv_file.read()
  140. except Exception:
  141. logger.exception("Не удалось прочитать CSV-файл FAQ")
  142. return []
  143. finally:
  144. try:
  145. csv_file.close()
  146. except Exception:
  147. pass
  148. try:
  149. text_content = raw_content.decode("utf-8-sig")
  150. except UnicodeDecodeError:
  151. text_content = raw_content.decode("cp1251", errors="ignore")
  152. sample = text_content[:2048]
  153. try:
  154. dialect = csv.Sniffer().sniff(sample, delimiters=";,\t")
  155. except csv.Error:
  156. dialect = csv.excel
  157. dialect.delimiter = ";"
  158. rows = []
  159. reader = csv.reader(io.StringIO(text_content), dialect)
  160. for index, row in enumerate(reader):
  161. if not row or not any(str(cell or "").strip() for cell in row):
  162. continue
  163. normalized_first = normalize_faq_text(row[0])
  164. if index == 0 and normalized_first in {"question", "вопрос", "q"}:
  165. continue
  166. question = str(row[0] if len(row) > 0 else "").strip()
  167. answer = str(row[1] if len(row) > 1 else "").strip()
  168. keywords = str(row[2] if len(row) > 2 else "").strip()
  169. if question and answer:
  170. rows.append({
  171. "question": question,
  172. "answer": answer,
  173. "keywords": split_faq_keywords(keywords) or split_faq_keywords(question),
  174. })
  175. return rows
  176. def find_faq_csv_answer(user_message: str, csv_file) -> Optional[str]:
  177. normalized_prompt = normalize_faq_text(user_message)
  178. if not normalized_prompt:
  179. return None
  180. for row in read_faq_csv_rows(csv_file):
  181. normalized_question = normalize_faq_text(row["question"])
  182. if normalized_question and (
  183. normalized_question in normalized_prompt
  184. or normalized_prompt in normalized_question
  185. ):
  186. return row["answer"]
  187. for keyword in row["keywords"]:
  188. if keyword and keyword in normalized_prompt:
  189. return row["answer"]
  190. return None
  191. def get_faq_response(user_message: str, landing_settings=None) -> str:
  192. csv_file = getattr(landing_settings, "faq_csv", None) if landing_settings else None
  193. if csv_file:
  194. prepared_answer = find_faq_csv_answer(user_message, csv_file)
  195. if prepared_answer:
  196. return prepared_answer
  197. return get_ai_or_file_response(user_message)
  198. def legal_module_enabled():
  199. try:
  200. from django.apps import apps
  201. return apps.is_installed("sharix_legal")
  202. except Exception:
  203. return False
  204. def _absolute_url(request, value):
  205. value = str(value or "").strip()
  206. if not value:
  207. return ""
  208. if value.startswith(("http://", "https://")):
  209. return value
  210. if request is None:
  211. return value
  212. try:
  213. return request.build_absolute_uri(value)
  214. except Exception:
  215. return value
  216. def _reverse_optional_url(request, url_name, kwargs=None):
  217. try:
  218. from django.urls import reverse
  219. return _absolute_url(request, reverse(url_name, kwargs=kwargs or {}))
  220. except Exception:
  221. return ""
  222. def _plain_text_to_html(content):
  223. from django.utils.html import escape
  224. lines = str(content or "").splitlines()
  225. html_parts = []
  226. for line in lines:
  227. line = line.strip()
  228. if not line:
  229. continue
  230. if line.startswith("======"):
  231. title = line.replace("=", "").strip()
  232. if title:
  233. html_parts.append(f"<h4>{escape(title)}</h4>")
  234. continue
  235. if line.startswith("===="):
  236. title = line.replace("=", "").strip()
  237. if title:
  238. html_parts.append(f"<h5>{escape(title)}</h5>")
  239. continue
  240. html_parts.append(f"<p>{escape(line)}</p>")
  241. return "\n".join(html_parts)
  242. def _doku_content_to_html(content):
  243. try:
  244. from sharix_legal.services.renderer import doku_to_plain_html
  245. return doku_to_plain_html(content)
  246. except Exception:
  247. return _plain_text_to_html(content)
  248. def _render_generated_service_agreement(service, request=None):
  249. """
  250. Пытается получить текст legal-документа для категории услуги.
  251. На странице конкретной услуги уже известна service.service_category,
  252. поэтому показываем не общий договор сервиса, а правила/договорные условия
  253. конкретной категории услуг.
  254. """
  255. if not legal_module_enabled():
  256. return ""
  257. category = getattr(service, "service_category", None)
  258. if category is None:
  259. return ""
  260. try:
  261. from sharix_legal.services.documents import render_category_rules
  262. content, _context = render_category_rules(
  263. category,
  264. request=request,
  265. )
  266. return content
  267. except Exception:
  268. return ""
  269. def _get_generated_service_agreement_url(request, service):
  270. """
  271. Возвращает ссылку на legal-документ категории услуги
  272. для открытия в отдельной вкладке.
  273. """
  274. if not legal_module_enabled():
  275. return ""
  276. category = getattr(service, "service_category", None)
  277. if category is None:
  278. return ""
  279. return _reverse_optional_url(
  280. request,
  281. "sharix_legal:category_rules",
  282. kwargs={"pk": category.pk},
  283. )
  284. def get_landing_category_agreement_url(request, category, metaservice=None):
  285. """Return the best available agreement URL for a catalogue category."""
  286. generated_url = ""
  287. if legal_module_enabled():
  288. generated_url = _reverse_optional_url(
  289. request,
  290. "sharix_legal:category_rules",
  291. kwargs={"pk": category.pk},
  292. )
  293. manual_url = (
  294. getattr(category, "link_agreement", "")
  295. or getattr(metaservice, "service_agreement", "")
  296. or ""
  297. )
  298. return generated_url or _absolute_url(request, manual_url)
  299. def get_landing_service_agreement_data(request, service, metaservice=None):
  300. """
  301. Возвращает данные договора для страницы landing service/<id>/.
  302. Если sharix_legal подключен и документ категории удалось сгенерировать,
  303. возвращается content_html для inline-отображения.
  304. Если inline-документ не сформирован, но есть ручная ссылка
  305. Metaservice.service_agreement, возвращается только ссылка.
  306. Если нет ни generated-документа, ни ручной ссылки,
  307. возвращается сообщение о незаполненных данных.
  308. """
  309. manual_url = getattr(metaservice, "service_agreement", "") or ""
  310. content = _render_generated_service_agreement(
  311. service=service,
  312. request=request,
  313. )
  314. generated_url = _get_generated_service_agreement_url(
  315. request=request,
  316. service=service,
  317. )
  318. if content:
  319. return {
  320. "url": generated_url or _absolute_url(request, manual_url),
  321. "content_html": _doku_content_to_html(content),
  322. "is_generated": True,
  323. "is_missing": False,
  324. "message": "",
  325. }
  326. if manual_url:
  327. return {
  328. "url": _absolute_url(request, manual_url),
  329. "content_html": "",
  330. "is_generated": False,
  331. "is_missing": False,
  332. "message": "",
  333. }
  334. return {
  335. "url": "",
  336. "content_html": "",
  337. "is_generated": False,
  338. "is_missing": True,
  339. "message": "Данные договора не заполнены.",
  340. }