diff --git a/criteria_interface_layout.json.save b/criteria_interface_layout.json.save
new file mode 100644
index 0000000..34661dd
--- /dev/null
+++ b/criteria_interface_layout.json.save
@@ -0,0 +1,801 @@
+{
+ "version": 1,
+ "source_xlsx": "Запрос для ИИ Общий(3).xlsx",
+ "by_shipping_type": {
+ "Автомобильная перевозка (LTL)": {
+ "sheet": "Авто LTL интерфейс",
+ "blocks": [
+ {
+ "type": "pair",
+ "left": {
+ "num": "4",
+ "label": "Страна, город, адрес забора"
+ },
+ "right": {
+ "num": "19",
+ "label": "Страна, город, точный адрес доставки, Место ТО в РФ"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "6",
+ "label": "Количество грузовых мест и вес, объем"
+ },
+ "right": {
+ "num": "7",
+ "label": "Габариты грузовых мест ДхШхВ"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "10",
+ "label": "Штабелирвоание с другими отправками"
+ },
+ "right": {
+ "num": "11",
+ "label": "Штабелирвоание между собой"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "9",
+ "label": "Наличие батарейк, газов, жидкостей, аэрозолей"
+ },
+ "right": {
+ "num": "12",
+ "label": "Батарейки в составе груза / отдельно"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "13",
+ "label": "MSDS"
+ },
+ "right": {
+ "num": "14",
+ "label": "DGM"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "15",
+ "label": "Название бренда"
+ },
+ "right": {
+ "num": "16",
+ "label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "17",
+ "label": "Требуется ли дополнительный сервис - фитоконтроль, иное"
+ },
+ "right": {
+ "num": "18",
+ "label": "Требуется ли замена документов"
+ }
+ }
+ ]
+ },
+ "Автомобильная перевозка (FTL)": {
+ "sheet": "Авто FTL интерфейс",
+ "blocks": [
+ {
+ "type": "pair",
+ "left": {
+ "num": "4",
+ "label": "Страна, город, адрес забора"
+ },
+ "right": {
+ "num": "19",
+ "label": "Страна, город, точный адрес доставки, Место ТО в РФ"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "6",
+ "label": "Количество и типоразмер машин"
+ },
+ "right": {
+ "num": "7",
+ "label": "Количество грузовых мест и вес, объем"
+ }
+ },
+ {
+ "type": "left_12",
+ "num": "8",
+ "label": "Габариты грузовых мест ДхШхВ"
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "10",
+ "label": "Наличие батарейк, газов, жидкостей, аэрозолей"
+ },
+ "right": {
+ "num": "12",
+ "label": "Батарейки в составе груза / отдельно"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "11",
+ "label": "MSDS"
+ },
+ "right": {
+ "num": "13",
+ "label": "DGM"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "14",
+ "label": "Название бренда"
+ },
+ "right": {
+ "num": "15",
+ "label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "16",
+ "label": "Требуется ли дополнительный сервис - фитоконтроль, иное"
+ },
+ "right": {
+ "num": "17",
+ "label": "Требуется ли замена документов"
+ }
+ }
+ ]
+ },
+ "Морская перевозка (LCL)": {
+ "sheet": "Море, море+жд, жд LCL интерфейc",
+ "blocks": [
+ {
+ "type": "pair",
+ "left": {
+ "num": "4",
+ "label": "Страна, город, адрес забора"
+ },
+ "right": {
+ "num": "19",
+ "label": "Страна, город, точный адрес доставки, Место ТО в РФ"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "6",
+ "label": "Количество и типоразмер машин"
+ },
+ "right": {
+ "num": "7",
+ "label": "Количество грузовых мест и вес, объем"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "10",
+ "label": "Штабелирвоание с другими отправками"
+ },
+ "right": {
+ "num": "11",
+ "label": "Штабелирвоание между собой"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "9",
+ "label": "Наличие батарейк, газов, жидкостей, аэрозолей"
+ },
+ "right": {
+ "num": "12",
+ "label": "Батарейки в составе груза / отдельно"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "13",
+ "label": "MSDS"
+ },
+ "right": {
+ "num": "14",
+ "label": "DGM"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "15",
+ "label": "Название бренда"
+ },
+ "right": {
+ "num": "16",
+ "label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "17",
+ "label": "Требуется ли дополнительный сервис - фитоконтроль, иное"
+ },
+ "right": {
+ "num": "18",
+ "label": "Требуется ли замена документов"
+ }
+ }
+ ]
+ },
+ "Железнодорожная перевозка (LCL)": {
+ "sheet": "Море, море+жд, жд LCL интерфейc",
+ "blocks": [
+ {
+ "type": "pair",
+ "left": {
+ "num": "4",
+ "label": "Страна, город, адрес забора"
+ },
+ "right": {
+ "num": "19",
+ "label": "Страна, город, точный адрес доставки, Место ТО в РФ"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "6",
+ "label": "Количество и типоразмер машин"
+ },
+ "right": {
+ "num": "7",
+ "label": "Количество грузовых мест и вес, объем"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "10",
+ "label": "Штабелирвоание с другими отправками"
+ },
+ "right": {
+ "num": "11",
+ "label": "Штабелирвоание между собой"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "9",
+ "label": "Наличие батарейк, газов, жидкостей, аэрозолей"
+ },
+ "right": {
+ "num": "12",
+ "label": "Батарейки в составе груза / отдельно"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "13",
+ "label": "MSDS"
+ },
+ "right": {
+ "num": "14",
+ "label": "DGM"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "15",
+ "label": "Название бренда"
+ },
+ "right": {
+ "num": "16",
+ "label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "17",
+ "label": "Требуется ли дополнительный сервис - фитоконтроль, иное"
+ },
+ "right": {
+ "num": "18",
+ "label": "Требуется ли замена документов"
+ }
+ }
+ ]
+ },
+ "Мультимодальная перевозка море + ж/д (LCL)": {
+ "sheet": "Море, море+жд, жд LCL интерфейc",
+ "blocks": [
+ {
+ "type": "pair",
+ "left": {
+ "num": "4",
+ "label": "Страна, город, адрес забора"
+ },
+ "right": {
+ "num": "19",
+ "label": "Страна, город, точный адрес доставки, Место ТО в РФ"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "6",
+ "label": "Количество и типоразмер машин"
+ },
+ "right": {
+ "num": "7",
+ "label": "Количество грузовых мест и вес, объем"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "10",
+ "label": "Штабелирвоание с другими отправками"
+ },
+ "right": {
+ "num": "11",
+ "label": "Штабелирвоание между собой"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "9",
+ "label": "Наличие батарейк, газов, жидкостей, аэрозолей"
+ },
+ "right": {
+ "num": "12",
+ "label": "Батарейки в составе груза / отдельно"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "13",
+ "label": "MSDS"
+ },
+ "right": {
+ "num": "14",
+ "label": "DGM"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "15",
+ "label": "Название бренда"
+ },
+ "right": {
+ "num": "16",
+ "label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "17",
+ "label": "Требуется ли дополнительный сервис - фитоконтроль, иное"
+ },
+ "right": {
+ "num": "18",
+ "label": "Требуется ли замена документов"
+ }
+ }
+ ]
+ },
+ "Морская перевозка (FCL)": {
+ "sheet": "Море, море+жд, жд FCL интерфейc",
+ "blocks": [
+ {
+ "type": "pair",
+ "left": {
+ "num": "4",
+ "label": "Страна, город, адрес забора"
+ },
+ "right": {
+ "num": "18",
+ "label": "Страна, город, точный адрес доставки, Место ТО в РФ"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "6",
+ "label": "Количество и типоразмер контейнеров"
+ },
+ "right": {
+ "num": "7",
+ "label": "Количество грузовых мест и вес, объем"
+ }
+ },
+ {
+ "type": "left_12",
+ "num": "8",
+ "label": "Габариты грузовых мест ДхШхВ"
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "10",
+ "label": "Наличие батарейк, газов, жидкостей, аэрозолей"
+ },
+ "right": {
+ "num": "12",
+ "label": "Батарейки в составе груза / отдельно"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "11",
+ "label": "MSDS"
+ },
+ "right": {
+ "num": "13",
+ "label": "Technical Description of Goods in Railway Transport"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "14",
+ "label": "Название бренда"
+ },
+ "right": {
+ "num": "15",
+ "label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "16",
+ "label": "Требуется ли дополнительный сервис - фитоконтроль, иное"
+ },
+ "right": {
+ "num": "17",
+ "label": "Требуется ли замена документов"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "19",
+ "label": "Место таможенного оформления при экспорте из РФ"
+ },
+ "right": {
+ "num": "20",
+ "label": "Наличие Фумигации на деревянной таре при экспорте из РФ"
+ }
+ },
+ {
+ "type": "left_12",
+ "num": "21",
+ "label": "Превышение габаритов дверного проема контейнеров 20DV, 40DV, 40HC"
+ }
+ ]
+ },
+ "Железнодорожная перевозка (FCL)": {
+ "sheet": "Море, море+жд, жд FCL интерфейc",
+ "blocks": [
+ {
+ "type": "pair",
+ "left": {
+ "num": "4",
+ "label": "Страна, город, адрес забора"
+ },
+ "right": {
+ "num": "18",
+ "label": "Страна, город, точный адрес доставки, Место ТО в РФ"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "6",
+ "label": "Количество и типоразмер контейнеров"
+ },
+ "right": {
+ "num": "7",
+ "label": "Количество грузовых мест и вес, объем"
+ }
+ },
+ {
+ "type": "left_12",
+ "num": "8",
+ "label": "Габариты грузовых мест ДхШхВ"
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "10",
+ "label": "Наличие батарейк, газов, жидкостей, аэрозолей"
+ },
+ "right": {
+ "num": "12",
+ "label": "Батарейки в составе груза / отдельно"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "11",
+ "label": "MSDS"
+ },
+ "right": {
+ "num": "13",
+ "label": "Technical Description of Goods in Railway Transport"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "14",
+ "label": "Название бренда"
+ },
+ "right": {
+ "num": "15",
+ "label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "16",
+ "label": "Требуется ли дополнительный сервис - фитоконтроль, иное"
+ },
+ "right": {
+ "num": "17",
+ "label": "Требуется ли замена документов"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "18",
+ "label": "Место таможенного оформления при экспорте из РФ"
+ },
+ "right": {
+ "num": "19",
+ "label": "Наличие Фумигации на деревянной таре при экспорте из РФ"
+ }
+ },
+ {
+ "type": "left_12",
+ "num": "20",
+ "label": "Превышение габаритов дверного проема контейнеров 20DV, 40DV, 40HC"
+ }
+ ]
+ },
+ "Мультимодальная перевозка море + ж/д (FCL)": {
+ "sheet": "Море, море+жд, жд FCL интерфейc",
+ "blocks": [
+ {
+ "type": "pair",
+ "left": {
+ "num": "4",
+ "label": "Страна, город, адрес забора"
+ },
+ "right": {
+ "num": "19",
+ "label": "Страна, город, точный адрес доставки, Место ТО в РФ"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "6",
+ "label": "Количество и типоразмер контейнеров"
+ },
+ "right": {
+ "num": "7",
+ "label": "Количество грузовых мест и вес, объем"
+ }
+ },
+ {
+ "type": "left_12",
+ "num": "8",
+ "label": "Габариты грузовых мест ДхШхВ"
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "10",
+ "label": "Наличие батарейк, газов, жидкостей, аэрозолей"
+ },
+ "right": {
+ "num": "12",
+ "label": "Батарейки в составе груза / отдельно"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "11",
+ "label": "MSDS"
+ },
+ "right": {
+ "num": "13",
+ "label": "Technical Description of Goods in Railway Transport"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "14",
+ "label": "Название бренда"
+ },
+ "right": {
+ "num": "15",
+ "label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "16",
+ "label": "Требуется ли дополнительный сервис - фитоконтроль, иное"
+ },
+ "right": {
+ "num": "17",
+ "label": "Требуется ли замена документов"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "18",
+ "label": "Место таможенного оформления при экспорте из РФ"
+ },
+ "right": {
+ "num": "19",
+ "label": "Наличие Фумигации на деревянной таре при экспорте из РФ"
+ }
+ },
+ {
+ "type": "left_12",
+ "num": "20",
+ "label": "Превышение габаритов дверного проема контейнеров 20DV, 40DV, 40HC"
+ }
+ ]
+ },
+ "Авиаперевозка": {
+ "sheet": "Авиа Интерфейс",
+ "blocks": [
+ {
+ "type": "pair",
+ "left": {
+ "num": "4",
+ "label": "Страна, город, адрес забора"
+ },
+ "right": {
+ "num": "21",
+ "label": "Страна, город, точный адрес доставки, Место ТО в РФ"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "6",
+ "label": "Количество грузовых мест и вес, объем"
+ },
+ "right": {
+ "num": "7",
+ "label": "Габариты грузовых мест ДхШхВ"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "10",
+ "label": "Штабелирвоание с другими отправками"
+ },
+ "right": {
+ "num": "11",
+ "label": "Штабелирвоание между собой"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "9",
+ "label": "Наличие батарейк, газов, жидкостей, аэрозолей"
+ },
+ "right": {
+ "num": "13",
+ "label": "Батарейки в составе груза / отдельно"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "12",
+ "label": "MSDS"
+ },
+ "right": {
+ "num": "14",
+ "label": "DGM"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "15",
+ "label": "Название бренда"
+ },
+ "right": {
+ "num": "16",
+ "label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "17",
+ "label": "Перелет через третью страну со сменой авинаклданых"
+ },
+ "right": {
+ "num": "18",
+ "label": "Наличие экспортной лицезии у отправителя"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "19",
+ "label": "Дополнительный сервис"
+ },
+ "right": {
+ "num": "20",
+ "label": "Экспедирование в аэропорту назначения"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "22",
+ "label": "Спец требования при доставке до адреса в РФ"
+ },
+ "right": {
+ "num": "23",
+ "label": "Таможенное оформление"
+ }
+ },
+ {
+ "type": "pair",
+ "left": {
+ "num": "24",
+ "label": "Место таможенного оформления при экспорте из РФ"
+ },
+ "right": {
+ "num": "25",
+ "label": "Наличие Фумигации на деревянной таре при экспорте из РФ"
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/document_processor.py b/document_processor.py
new file mode 100644
index 0000000..6513b1e
--- /dev/null
+++ b/document_processor.py
@@ -0,0 +1,815 @@
+import io
+import os
+import re
+import csv
+import tempfile
+import subprocess
+import logging
+
+from typing import List
+
+from bs4 import BeautifulSoup
+
+from pypdf import PdfReader
+from docx import Document
+
+import openpyxl
+import pdfplumber
+
+from PIL import Image
+import pytesseract
+
+from striprtf.striprtf import rtf_to_text
+
+
+logger = logging.getLogger(__name__)
+
+
+class DocumentProcessor:
+
+ # =========================================================
+ # HELPERS
+ # =========================================================
+
+ @staticmethod
+ def _cleanup_text(text: str) -> str:
+
+ if not text:
+ return ""
+
+ text = text.replace("\xa0", " ")
+ text = text.replace("\t", " ")
+
+ text = re.sub(r"[ \t]+", " ", text)
+ text = re.sub(r"\n{3,}", "\n\n", text)
+
+ return text.strip()
+
+ @staticmethod
+ def _cell_to_text(v) -> str:
+
+ if v is None:
+ return ""
+
+ s = str(v).strip()
+
+ return s if s else ""
+
+ # =========================================================
+ # HTML
+ # =========================================================
+
+ @staticmethod
+ def normalize_email_html(html_content: str) -> str:
+ """
+ Очищает HTML письма и нормализует таблицы
+ """
+
+ soup = BeautifulSoup(
+ html_content,
+ "html.parser"
+ )
+
+ for tag in soup(["script", "style"]):
+ tag.decompose()
+
+ for table in soup.find_all("table"):
+
+ table["style"] = (
+ "border-collapse: collapse; width: 100%;"
+ )
+
+ for cell in table.find_all(["td", "th"]):
+
+ cell["style"] = (
+ "border:1px solid #ccc;padding:6px;"
+ )
+
+ return str(soup)
+
+ # =========================================================
+ # MAIN
+ # =========================================================
+
+ def extract_text(
+ self,
+ content: bytes,
+ filename: str
+ ) -> str:
+
+ ext = filename.lower().split(".")[-1]
+
+ try:
+
+ # =================================================
+ # PDF
+ # =================================================
+
+ if ext == "pdf":
+ return self._extract_pdf(content)
+
+ # =================================================
+ # WORD
+ # =================================================
+
+ elif ext in ["docx", "docm"]:
+ return self._extract_docx(content)
+
+ elif ext == "doc":
+ return self._extract_doc(content)
+
+ elif ext == "rtf":
+ return self._extract_rtf(content)
+
+ # =================================================
+ # EXCEL
+ # =================================================
+
+ elif ext in ["xlsx", "xlsm", "xlsxm"]:
+ return self._extract_excel_openpyxl(content)
+
+ elif ext == "xls":
+ return self._extract_excel_xls(content)
+
+ elif ext == "csv":
+ return self._extract_csv(content)
+
+ # =================================================
+ # IMAGES / OCR
+ # =================================================
+
+ elif ext in [
+ "png",
+ "jpg",
+ "jpeg",
+ "gif",
+ "bmp",
+ "webp"
+ ]:
+ return self._extract_image(content)
+
+ elif ext in ["tif", "tiff"]:
+ return self._extract_tiff(content)
+
+ # =================================================
+ # TEXT
+ # =================================================
+
+ elif ext == "txt":
+
+ try:
+ return content.decode("utf-8")
+
+ except Exception:
+ return content.decode(
+ "cp1251",
+ errors="ignore"
+ )
+
+ # =================================================
+ # UNKNOWN
+ # =================================================
+
+ logger.warning(
+ f"Unsupported attachment format: {ext}"
+ )
+
+ return ""
+
+ except Exception:
+
+ logger.exception(
+ f"Attachment processing failed: {filename}"
+ )
+
+ return ""
+
+ # =========================================================
+ # PDF
+ # =========================================================
+
+ def _extract_pdf(
+ self,
+ content: bytes
+ ) -> str:
+
+ try:
+
+ parts = []
+
+ with pdfplumber.open(
+ io.BytesIO(content)
+ ) as pdf:
+
+ for page_num, page in enumerate(
+ pdf.pages,
+ start=1
+ ):
+
+ parts.append(
+ f"=== PAGE {page_num} ==="
+ )
+
+ # =====================================
+ # TEXT
+ # =====================================
+
+ try:
+
+ text = page.extract_text()
+
+ if text:
+ parts.append(text)
+
+ except Exception as e:
+
+ logger.warning(
+ f"PDF text extraction failed: {e}"
+ )
+
+ # =====================================
+ # TABLES
+ # =====================================
+
+ try:
+
+ tables = page.extract_tables()
+
+ for table_idx, table in enumerate(
+ tables,
+ start=1
+ ):
+
+ parts.append(
+ f"=== TABLE {table_idx} ==="
+ )
+
+ for row in table:
+
+ if not row:
+ continue
+
+ cells = []
+
+ for cell in row:
+
+ if cell is None:
+ continue
+
+ txt = str(cell).strip()
+
+ txt = " ".join(
+ txt.split()
+ )
+
+ if txt:
+ cells.append(txt)
+
+ if cells:
+
+ parts.append(
+ " | ".join(cells)
+ )
+
+ except Exception as e:
+
+ logger.warning(
+ f"PDF table extraction failed: {e}"
+ )
+
+ text = "\n".join(parts)
+
+ return self._cleanup_text(text)
+
+ except Exception as e:
+
+ logger.error(f"PDF parse failed: {e}")
+
+ return ""
+
+ # =========================================================
+ # DOCX / DOCM
+ # =========================================================
+
+ def _extract_docx(
+ self,
+ content: bytes
+ ) -> str:
+
+ try:
+
+ doc = Document(io.BytesIO(content))
+
+ parts = []
+
+ # =============================================
+ # PARAGRAPHS
+ # =============================================
+
+ for p in doc.paragraphs:
+
+ txt = p.text.strip()
+
+ if txt:
+ parts.append(txt)
+
+ # =============================================
+ # TABLES
+ # =============================================
+
+ for table_idx, table in enumerate(
+ doc.tables
+ ):
+
+ parts.append(
+ f"=== TABLE {table_idx + 1} ==="
+ )
+
+ for row in table.rows:
+
+ cells = []
+
+ for cell in row.cells:
+
+ cell_parts = []
+
+ # =================================
+ # DIRECT CELL TEXT
+ # =================================
+
+ txt = "\n".join(
+ p.text.strip()
+ for p in cell.paragraphs
+ if p.text.strip()
+ )
+
+ txt = " ".join(txt.split())
+
+ if txt:
+ cell_parts.append(txt)
+
+ # =================================
+ # NESTED TABLES
+ # =================================
+
+ for nested_table in cell.tables:
+
+ for nested_row in nested_table.rows:
+
+ nested_vals = []
+
+ for nested_cell in nested_row.cells:
+
+ nested_txt = "\n".join(
+ p.text.strip()
+ for p in nested_cell.paragraphs
+ if p.text.strip()
+ )
+
+ nested_txt = " ".join(
+ nested_txt.split()
+ )
+
+ if nested_txt:
+ nested_vals.append(
+ nested_txt
+ )
+
+ if nested_vals:
+
+ cell_parts.append(
+ " || ".join(
+ nested_vals
+ )
+ )
+
+ # =================================
+ # FINAL CELL
+ # =================================
+
+ if cell_parts:
+
+ cells.append(
+ " <<
>> ".join(
+ cell_parts
+ )
+ )
+
+ if cells:
+
+ parts.append(
+ " | ".join(cells)
+ )
+
+ text = "\n".join(parts)
+
+ return self._cleanup_text(text)
+
+ except Exception as e:
+
+ logger.error(f"DOCX parse failed: {e}")
+
+ return ""
+
+ # =========================================================
+ # DOC
+ # =========================================================
+
+ def _extract_doc(
+ self,
+ content: bytes
+ ) -> str:
+
+ try:
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+
+ input_path = os.path.join(
+ tmpdir,
+ "temp.doc"
+ )
+
+ with open(input_path, "wb") as f:
+ f.write(content)
+
+ subprocess.run(
+ [
+ "libreoffice",
+ "--headless",
+ "--convert-to",
+ "docx",
+ input_path,
+ "--outdir",
+ tmpdir,
+ ],
+ check=True,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+
+ converted_path = os.path.join(
+ tmpdir,
+ "temp.docx"
+ )
+
+ if not os.path.exists(
+ converted_path
+ ):
+
+ logger.error(
+ "DOC conversion failed"
+ )
+
+ return ""
+
+ with open(
+ converted_path,
+ "rb"
+ ) as f:
+
+ converted_content = f.read()
+
+ return self._extract_docx(
+ converted_content
+ )
+
+ except Exception as e:
+
+ logger.error(f"DOC parse failed: {e}")
+
+ return ""
+
+ # =========================================================
+ # RTF
+ # =========================================================
+
+ def _extract_rtf(
+ self,
+ content: bytes
+ ) -> str:
+
+ try:
+
+ try:
+ text = content.decode("utf-8")
+
+ except Exception:
+ text = content.decode(
+ "cp1251",
+ errors="ignore"
+ )
+
+ text = rtf_to_text(text)
+
+ return self._cleanup_text(text)
+
+ except Exception as e:
+
+ logger.error(f"RTF parse failed: {e}")
+
+ return ""
+
+ # =========================================================
+ # XLSX
+ # =========================================================
+
+ def _extract_excel_openpyxl(
+ self,
+ content: bytes
+ ) -> str:
+
+ try:
+
+ wb = openpyxl.load_workbook(
+ io.BytesIO(content),
+ data_only=True
+ )
+
+ text = []
+
+ for sheet in wb.worksheets:
+
+ text.append(
+ f"=== SHEET: {sheet.title} ==="
+ )
+
+ # =====================================
+ # MERGED CELLS
+ # =====================================
+
+ merged_ranges = list(
+ sheet.merged_cells.ranges
+ )
+
+ if merged_ranges:
+
+ text.append(
+ "=== MERGED CELLS ==="
+ )
+
+ for r in merged_ranges:
+ text.append(str(r))
+
+ # =====================================
+ # ROWS
+ # =====================================
+
+ text.append("[ROWS]")
+
+ for row in sheet.iter_rows(
+ values_only=True
+ ):
+
+ vals = [
+ self._cell_to_text(c)
+ for c in row
+ ]
+
+ vals = [x for x in vals if x]
+
+ if vals:
+
+ text.append(
+ " | ".join(vals)
+ )
+
+ # =====================================
+ # COLUMNS
+ # =====================================
+
+ text.append("[COLUMNS]")
+
+ max_col = sheet.max_column or 0
+ max_row = sheet.max_row or 0
+
+ for c in range(1, max_col + 1):
+
+ vals = []
+
+ for r in range(1, max_row + 1):
+
+ v = self._cell_to_text(
+ sheet.cell(r, c).value
+ )
+
+ if v:
+ vals.append(v)
+
+ if vals:
+
+ col_letter = (
+ openpyxl.utils
+ .get_column_letter(c)
+ )
+
+ text.append(
+ f"COL {col_letter}: "
+ + " || ".join(vals)
+ )
+
+ return self._cleanup_text(
+ "\n".join(text)
+ )
+
+ except Exception as e:
+
+ logger.error(
+ f"XLSX parse failed: {e}"
+ )
+
+ return ""
+
+ # =========================================================
+ # XLS
+ # =========================================================
+
+ def _extract_excel_xls(
+ self,
+ content: bytes
+ ) -> str:
+
+ try:
+
+ import xlrd
+
+ workbook = xlrd.open_workbook(
+ file_contents=content
+ )
+
+ text = []
+
+ for sheet in workbook.sheets():
+
+ text.append(
+ f"=== SHEET: {sheet.name} ==="
+ )
+
+ for row_idx in range(
+ sheet.nrows
+ ):
+
+ row = sheet.row_values(
+ row_idx
+ )
+
+ vals = [
+ self._cell_to_text(c)
+ for c in row
+ ]
+
+ vals = [x for x in vals if x]
+
+ if vals:
+
+ text.append(
+ " | ".join(vals)
+ )
+
+ return self._cleanup_text(
+ "\n".join(text)
+ )
+
+ except Exception as e:
+
+ logger.error(f"XLS parse failed: {e}")
+
+ return ""
+
+ # =========================================================
+ # CSV
+ # =========================================================
+
+ def _extract_csv(
+ self,
+ content: bytes
+ ) -> str:
+
+ try:
+
+ try:
+ text = content.decode("utf-8")
+
+ except Exception:
+ text = content.decode(
+ "cp1251",
+ errors="ignore"
+ )
+
+ reader = csv.reader(
+ io.StringIO(text)
+ )
+
+ rows = []
+
+ for row in reader:
+
+ vals = [
+ str(x).strip()
+ for x in row
+ if str(x).strip()
+ ]
+
+ if vals:
+
+ rows.append(
+ " | ".join(vals)
+ )
+
+ return self._cleanup_text(
+ "\n".join(rows)
+ )
+
+ except Exception as e:
+
+ logger.error(f"CSV parse failed: {e}")
+
+ return ""
+
+ # =========================================================
+ # OCR
+ # =========================================================
+
+ def _ocr_image(
+ self,
+ image: Image.Image
+ ) -> str:
+
+ try:
+
+ text = pytesseract.image_to_string(
+ image,
+ lang="rus+eng",
+ config="--psm 6"
+ )
+
+ return self._cleanup_text(text)
+
+ except Exception as e:
+
+ logger.error(f"OCR failed: {e}")
+
+ return ""
+
+ # =========================================================
+ # IMAGE
+ # =========================================================
+
+ def _extract_image(
+ self,
+ content: bytes
+ ) -> str:
+
+ try:
+
+ image = Image.open(
+ io.BytesIO(content)
+ )
+
+ return self._ocr_image(image)
+
+ except Exception as e:
+
+ logger.error(
+ f"Image parse failed: {e}"
+ )
+
+ return ""
+
+ # =========================================================
+ # TIFF
+ # =========================================================
+
+ def _extract_tiff(
+ self,
+ content: bytes
+ ) -> str:
+
+ try:
+
+ image = Image.open(
+ io.BytesIO(content)
+ )
+
+ texts = []
+
+ for frame in range(
+ getattr(image, "n_frames", 1)
+ ):
+
+ image.seek(frame)
+
+ texts.append(
+ self._ocr_image(image)
+ )
+
+ return self._cleanup_text(
+ "\n".join(texts)
+ )
+
+ except Exception as e:
+
+ logger.error(
+ f"TIFF parse failed: {e}"
+ )
+
+ return ""
diff --git a/extract_criteria_interface_layout.py b/extract_criteria_interface_layout.py
index cf44a3f..b3268e0 100644
--- a/extract_criteria_interface_layout.py
+++ b/extract_criteria_interface_layout.py
@@ -1,126 +1,138 @@
-"""
-Однократная/повторная генерация criteria_interface_layout.json из Excel
-«Запрос для ИИ Общий(2).xlsx» (листы с «интерфейс» в названии).
-
-Запуск (путь к xlsx можно передать аргументом):
- py extract_criteria_interface_layout.py "C:\\Users\\...\\Запрос для ИИ Общий(2).xlsx"
-"""
-from __future__ import annotations
-
-import json
-import os
-import sys
-
-import openpyxl
-
-SHEET_TO_SHIPPING_TYPES: dict[str, list[str]] = {
- "Авто LTL интерфейс": ["Автомобильная перевозка (LTL)"],
- "Авто FTL интерфейс": ["Автомобильная перевозка (FTL)"],
- "Море, море+жд, жд LCL интерфейc": [
- "Морская перевозка (LCL)",
- "Железнодорожная перевозка (LCL)",
- "Мультимодальная перевозка море + ж/д (LCL)",
- ],
- "Море, море+жд, жд FCL интерфейc": [
- "Морская перевозка (FCL)",
- "Железнодорожная перевозка (FCL)",
- "Мультимодальная перевозка море + ж/д (FCL)",
- ],
- "Авиа Интерфейс": ["Авиаперевозка"],
-}
-
-
-def _row_cells(ws, r: int, max_c: int = 12) -> list[tuple[int, str]]:
- out: list[tuple[int, str]] = []
- for c in range(1, max_c + 1):
- v = ws.cell(r, c).value
- if v is not None and str(v).strip():
- out.append((c, str(v).strip()))
- return out
-
-
-def parse_interface_sheet(ws) -> list[dict]:
- """Разбор листа интерфейса в последовательность визуальных блоков."""
- blocks: list[dict] = []
- r = 1
- max_r = ws.max_row or 1
- while r <= max_r:
- cells = _row_cells(ws, r)
- if not cells:
- r += 1
- continue
-
- # Заголовок: 7 ячеек в колонках 1,2,3,4,5,7,9 и все значения — «номера пунктов»
- if (
- len(cells) == 7
- and {c[0] for c in cells} == {1, 2, 3, 4, 5, 7, 9}
- and all(str(c[1]).strip().isdigit() or c[1].strip().replace(".", "").isdigit() for c in cells)
- ):
- nums = [c[1] for c in sorted(cells, key=lambda x: (x[0] not in (1, 2, 3, 4, 5, 7, 9), x[0]))]
- # порядок как в Excel слева направо по колонкам
- nums = [c[1] for c in sorted(cells, key=lambda x: x[0])]
- r2 = r + 1
- cells2 = _row_cells(ws, r2)
- if len(cells2) == 7 and {c[0] for c in cells2} == {1, 2, 3, 4, 5, 7, 9}:
- labels = [c[1] for c in sorted(cells2, key=lambda x: x[0])]
- blocks.append({"type": "header", "nums": nums, "labels": labels})
- r += 2
- continue
-
- # Только правая пара (6 = номер, 7 = текст) — например «20 Место таможни»
- if len(cells) == 2 and cells[0][0] == 6 and cells[1][0] == 7:
- blocks.append({"type": "right_67", "num": cells[0][1], "label": cells[1][1]})
- r += 1
- continue
-
- # Две пары: 1-2 и 6-7
- if len(cells) == 4 and {cells[i][0] for i in range(4)} == {1, 2, 6, 7}:
- blocks.append(
- {
- "type": "pair",
- "left": {"num": cells[0][1], "label": cells[1][1]},
- "right": {"num": cells[2][1], "label": cells[3][1]},
- }
- )
- r += 1
- continue
-
- # Только левая пара 1-2 (один блок на строку)
- if len(cells) == 2 and cells[0][0] == 1 and cells[1][0] == 2:
- blocks.append({"type": "left_12", "num": cells[0][1], "label": cells[1][1]})
- r += 1
- continue
-
- r += 1
- return blocks
-
-
-def main() -> None:
- xlsx = (
- sys.argv[1]
- if len(sys.argv) > 1
- else os.path.join(os.path.dirname(__file__), "Запрос для ИИ Общий(3).xlsx")
- )
- if not os.path.isfile(xlsx):
- print("Файл не найден:", xlsx)
- sys.exit(1)
-
- wb = openpyxl.load_workbook(xlsx, read_only=False, data_only=True)
- out: dict = {"version": 1, "source_xlsx": os.path.basename(xlsx), "by_shipping_type": {}}
-
- for sheet_name, type_names in SHEET_TO_SHIPPING_TYPES.items():
- if sheet_name not in wb.sheetnames:
- print("Пропуск: лист не найден:", sheet_name)
- continue
- blocks = parse_interface_sheet(wb[sheet_name])
- for tn in type_names:
- out["by_shipping_type"][tn] = {"sheet": sheet_name, "blocks": blocks}
-
- out_path = os.path.join(os.path.dirname(__file__), "criteria_interface_layout.json")
- with open(out_path, "w", encoding="utf-8") as f:
- json.dump(out, f, ensure_ascii=False, indent=2)
- print("Записано:", out_path, "типов:", len(out["by_shipping_type"]))
-
-
-if __name__ == "__main__":
- main()
+"""
+Однократная/повторная генерация criteria_interface_layout.json из Excel
+«Запрос для ИИ Общий(2).xlsx» (листы с «интерфейс» в названии).
+
+Запуск (путь к xlsx можно передать аргументом):
+ py extract_criteria_interface_layout.py "C:\\Users\\...\\Запрос для ИИ Общий(2).xlsx"
+"""
+from __future__ import annotations
+
+import json
+import os
+import sys
+
+import openpyxl
+
+SHEET_TO_SHIPPING_TYPES: dict[str, list[str]] = {
+ "Авто LTL интерфейс": ["Автомобильная перевозка (LTL)"],
+ "Авто FTL интерфейс": ["Автомобильная перевозка (FTL)"],
+ "Море, море+жд, жд LCL интерфейc": [
+ "Морская перевозка (LCL)",
+ "Железнодорожная перевозка (LCL)",
+ "Мультимодальная перевозка море + ж/д (LCL)",
+ ],
+ "Море, море+жд, жд FCL интерфейc": [
+ "Морская перевозка (FCL)",
+ "Железнодорожная перевозка (FCL)",
+ "Мультимодальная перевозка море + ж/д (FCL)",
+ ],
+ "Авиа Интерфейс": ["Авиаперевозка"],
+}
+
+
+def _row_cells(ws, r: int, max_c: int = 12) -> list[tuple[int, str]]:
+ out: list[tuple[int, str]] = []
+ for c in range(1, max_c + 1):
+ v = ws.cell(r, c).value
+ if v is not None and str(v).strip():
+ out.append((c, str(v).strip()))
+ return out
+
+
+def _is_num_like(s: str) -> bool:
+ if not isinstance(s, str):
+ return False
+ t = s.strip()
+ if not t:
+ return False
+ # 1, 1., 1.0
+ return t.isdigit() or t.replace(".", "", 1).isdigit()
+
+
+def parse_interface_sheet(ws) -> list[dict]:
+ """Разбор листа интерфейса в последовательность визуальных блоков."""
+ blocks: list[dict] = []
+ r = 1
+ max_r = ws.max_row or 1
+ while r <= max_r:
+ cells = _row_cells(ws, r)
+ if not cells:
+ r += 1
+ continue
+
+ # Заголовок верхнего ряда:
+ # в новых версиях файла набор колонок может отличаться, поэтому
+ # распознаём динамически: строка с номерами + следующая строка с подписями
+ # в тех же колонках.
+ cols = [c[0] for c in cells]
+ vals = [c[1] for c in cells]
+ if len(cells) >= 4 and all(_is_num_like(v) for v in vals):
+ r2 = r + 1
+ cells2 = _row_cells(ws, r2)
+ map2 = {c: v for c, v in cells2}
+ if all(c in map2 and str(map2[c]).strip() for c in cols):
+ labels = [str(map2[c]).strip() for c in cols]
+ # Если в следующей строке снова только числа — это не шапка.
+ if not all(_is_num_like(v) for v in labels):
+ nums = [str(v).strip() for v in vals]
+ blocks.append({"type": "header", "nums": nums, "labels": labels})
+ r += 2
+ continue
+
+ # Только правая пара (6 = номер, 7 = текст) — например «20 Место таможни»
+ if len(cells) == 2 and cells[0][0] == 6 and cells[1][0] == 7:
+ blocks.append({"type": "right_67", "num": cells[0][1], "label": cells[1][1]})
+ r += 1
+ continue
+
+ # Две пары: 1-2 и 6-7
+ if len(cells) == 4 and {cells[i][0] for i in range(4)} == {1, 2, 6, 7}:
+ blocks.append(
+ {
+ "type": "pair",
+ "left": {"num": cells[0][1], "label": cells[1][1]},
+ "right": {"num": cells[2][1], "label": cells[3][1]},
+ }
+ )
+ r += 1
+ continue
+
+ # Только левая пара 1-2 (один блок на строку)
+ if len(cells) == 2 and cells[0][0] == 1 and cells[1][0] == 2:
+ blocks.append({"type": "left_12", "num": cells[0][1], "label": cells[1][1]})
+ r += 1
+ continue
+
+ r += 1
+ return blocks
+
+
+def main() -> None:
+ xlsx = (
+ sys.argv[1]
+ if len(sys.argv) > 1
+ else os.path.join(os.path.dirname(__file__), "Запрос для ИИ Общий(3).xlsx")
+ )
+ if not os.path.isfile(xlsx):
+ print("Файл не найден:", xlsx)
+ sys.exit(1)
+
+ wb = openpyxl.load_workbook(xlsx, read_only=False, data_only=True)
+ out: dict = {"version": 1, "source_xlsx": os.path.basename(xlsx), "by_shipping_type": {}}
+
+ for sheet_name, type_names in SHEET_TO_SHIPPING_TYPES.items():
+ if sheet_name not in wb.sheetnames:
+ print("Пропуск: лист не найден:", sheet_name)
+ continue
+ blocks = parse_interface_sheet(wb[sheet_name])
+ for tn in type_names:
+ out["by_shipping_type"][tn] = {"sheet": sheet_name, "blocks": blocks}
+
+ out_path = os.path.join(os.path.dirname(__file__), "criteria_interface_layout.json")
+ with open(out_path, "w", encoding="utf-8") as f:
+ json.dump(out, f, ensure_ascii=False, indent=2)
+ print("Записано:", out_path, "типов:", len(out["by_shipping_type"]))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/rag_engine_gemini.py b/rag_engine_gemini.py
new file mode 100644
index 0000000..775a235
--- /dev/null
+++ b/rag_engine_gemini.py
@@ -0,0 +1,5379 @@
+import os
+import logging
+import hashlib
+import json
+import re
+import time
+import base64
+from typing import Dict, List, Optional, Any, Union, Tuple
+from datetime import datetime
+from openai import OpenAI
+from dotenv import load_dotenv
+from document_processor import DocumentProcessor
+from shipping_calculator import calculate_shipping_cost
+
+try:
+ from container_reference import iso_reference_prompt_block
+except ImportError:
+ def iso_reference_prompt_block() -> str:
+ return ""
+
+load_dotenv()
+logger = logging.getLogger(__name__)
+logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+
+_SHIPPING_DIR = os.path.dirname(__file__)
+
+
+def _normalize_dimension_unit_field(raw: Any) -> Optional[str]:
+ """Значение поля unit / dimension_unit из JSON: cm | mm | m или None."""
+ if raw is None:
+ return None
+ s = str(raw).strip().lower()
+ if s in ("mm", "мм", "millimeter", "millimeters", "миллиметр", "миллиметры"):
+ return "mm"
+ if s in ("cm", "см", "centimeter", "centimeters", "сантиметр", "сантиметры"):
+ return "cm"
+ if s in ("m", "meter", "metre", "metres", "meters", "метр", "метры", "м"):
+ return "m"
+ return None
+
+
+def _line_hint_dimension_unit(line: str) -> Optional[str]:
+ """По строке письма/таблицы: явные см/мм рядом с габаритами."""
+ if not isinstance(line, str) or not line.strip():
+ return None
+ ll = line.lower()
+ has_mm = bool(re.search(r"(? int:
+ """
+ Максимальная длина строки контекста (письма + полный текст вложений) для запроса к LLM.
+ Переменная окружения RAG_MAX_CONTEXT_CHARS:
+ - не задана → 0 (без обрезки; при необходимости ограничения задайте положительное число);
+ - 0 → без обрезки (осторожно: очень большие письма/вложения увеличат запрос к API).
+ """
+ raw = os.getenv("RAG_MAX_CONTEXT_CHARS", "0").strip()
+ if raw == "0":
+ return 0
+ try:
+ n = int(raw)
+ return n if n > 0 else 400_000
+ except ValueError:
+ return 400_000
+
+
+def _max_cargo_segment_chars() -> int:
+ """
+ Лимит длины сегмента cargo_description при разбиении по кодам позиций.
+ RAG_MAX_SEGMENT_CHARS: не задана → 2_000_000; 0 → без обрезки.
+ """
+ raw = os.getenv("RAG_MAX_SEGMENT_CHARS", "2000000").strip()
+ if raw == "0":
+ return 0
+ try:
+ n = int(raw)
+ return n if n > 0 else 2_000_000
+ except ValueError:
+ return 2_000_000
+
+
+def _criteria_preview_chars() -> int:
+ """
+ Лимит символов поля criteria каждого типа перевозки в JSON промпта (не письма).
+ RAG_CRITERIA_PREVIEW_CHARS: по умолчанию 8000; 0 — без обрезки, целиком как в shipping_types.
+ """
+ raw = os.getenv("RAG_CRITERIA_PREVIEW_CHARS", "8000").strip()
+ try:
+ return int(raw)
+ except ValueError:
+ return 8000
+
+
+def _mandatory_counterparty_chars() -> int:
+ """
+ То же для mandatory_counterparty_criteria в промпте. По умолчанию 4000; 0 — без обрезки.
+ """
+ raw = os.getenv("RAG_MANDATORY_COUNTERPARTY_CHARS", "4000").strip()
+ try:
+ return int(raw)
+ except ValueError:
+ return 4000
+
+
+def _limit_prompt_field(text: str, max_chars: int) -> str:
+ """Обрезка только для вставки длинных полей справочника в промпт; max_chars <= 0 — полный текст."""
+ s = text if isinstance(text, str) else ""
+ if max_chars <= 0:
+ return s
+ return s[:max_chars]
+
+
+def _json_prompt_compact(obj: Any) -> str:
+ """JSON для LLM без отступов и лишних пробелов — меньше токенов, чем indent=2."""
+ return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
+
+
+def _email_thread_dedupe_enabled() -> bool:
+ """RAG_EMAIL_THREAD_DEDUPE=0|false — не удалять повторяющиеся абзацы в теле для промпта."""
+ raw = os.getenv("RAG_EMAIL_THREAD_DEDUPE", "1").strip().lower()
+ if raw in ("0", "false", "no", "off", "disable", "disabled"):
+ return False
+ return True
+
+
+def _dedupe_email_thread_paragraphs(text: str, min_para_chars: int = 48) -> str:
+ """
+ Убирает дословно повторяющиеся абзацы (типичные дисклеймеры «confidential…» в цепочке FW/RE).
+ Не режет текст по длине — только дубликаты блоков.
+ """
+ if not isinstance(text, str) or not text.strip():
+ return text
+ parts = re.split(r"(\n\s*\n)", text)
+ out: List[str] = []
+ seen: set[str] = set()
+ for i, part in enumerate(parts):
+ if i % 2 == 1:
+ out.append(part)
+ continue
+ key = part.strip()
+ if len(key) < min_para_chars:
+ out.append(part)
+ continue
+ if key in seen:
+ continue
+ seen.add(key)
+ out.append(part)
+ s = "".join(out)
+ s = re.sub(r"\n{5,}", "\n\n\n\n", s)
+ return s
+
+
+def _shipping_types_prompt_k() -> int:
+ """
+ Сколько типов перевозок подставлять в основной промпт (по скорингу keywords по тексту писем).
+ 0 — все типы (как раньше). 8–15 обычно сильно режет токены без обрезки писем/вложений.
+ RAG_SHIPPING_TYPES_PROMPT_K
+ """
+ raw = os.getenv("RAG_SHIPPING_TYPES_PROMPT_K", "0").strip()
+ try:
+ return int(raw)
+ except ValueError:
+ return 0
+
+
+def _report_cache_enabled() -> bool:
+ """RAG_REPORT_CACHE=0|false отключает дисковый кэш результатов query_cargo_info."""
+ raw = os.getenv("RAG_REPORT_CACHE", "1").strip().lower()
+ if raw in ("0", "false", "no", "off", "disable", "disabled"):
+ return False
+ return True
+
+
+def _report_cache_dir() -> str:
+ base = os.getenv("RAG_CACHE_DIR", "").strip()
+ if not base:
+ base = os.path.join(_SHIPPING_DIR, ".rag_cache")
+ return os.path.join(base, "cargo_reports")
+
+
+def _report_cache_bust_token() -> str:
+ """Произвольная строка для инвалидации кэша после смены логики промпта (RAG_CACHE_BUST)."""
+ return (os.getenv("RAG_CACHE_BUST") or "").strip()
+
+
+def _learning_store_path() -> str:
+ raw = (os.getenv("RAG_LEARNING_STORE") or "").strip()
+ if raw:
+ return raw
+ return os.path.join(_SHIPPING_DIR, "cargo_rag_learning.json")
+
+
+def _learning_auto_enabled() -> bool:
+ """RAG_LEARNING_AUTO=0 отключает автосохранение пар «контекст → structured_data» после анализа."""
+ raw = (os.getenv("RAG_LEARNING_AUTO") or "1").strip().lower()
+ return raw not in ("0", "false", "no", "off", "disable", "disabled")
+
+
+def _learning_few_shot_enabled() -> bool:
+ """RAG_LEARNING_FEW_SHOT=0 не подмешивает примеры в промпт."""
+ raw = (os.getenv("RAG_LEARNING_FEW_SHOT") or "1").strip().lower()
+ return raw not in ("0", "false", "no", "off", "disable", "disabled")
+
+
+def _learning_max_store() -> int:
+ try:
+ n = int((os.getenv("RAG_LEARNING_MAX") or "50").strip())
+ return max(1, min(n, 500))
+ except ValueError:
+ return 50
+
+
+def _learning_few_shot_count() -> int:
+ try:
+ n = int((os.getenv("RAG_LEARNING_FEW_SHOT_N") or "2").strip())
+ return max(0, min(n, 5))
+ except ValueError:
+ return 2
+
+
+def _context_word_set(text: str, max_words: int = 400) -> set[str]:
+ if not isinstance(text, str) or not text.strip():
+ return set()
+ words = re.findall(r"[a-zA-Zа-яА-ЯёЁ0-9]{3,}", text.lower())
+ stop = {
+ "the", "and", "for", "not", "this", "that", "with", "from",
+ "для", "это", "все", "как", "что", "при", "или", "также", "если",
+ "когда", "будет", "есть", "были", "было", "этот", "этой",
+ }
+ out: set[str] = set()
+ for w in words:
+ if w in stop:
+ continue
+ out.add(w)
+ if len(out) >= max_words:
+ break
+ return out
+
+
+def _truncate_structured_for_learning(sd: Any, *, max_shipments: int = 2, max_str: int = 400) -> Dict:
+ """Укорачивает JSON для хранения и few-shot, чтобы не раздувать промпт."""
+ if not isinstance(sd, dict):
+ return {}
+
+ def _trim(val: Any, depth: int = 0) -> Any:
+ if depth > 6:
+ return None
+ if isinstance(val, str):
+ s = val.strip()
+ return s[:max_str] + ("…" if len(s) > max_str else "")
+ if isinstance(val, (int, float, bool)) or val is None:
+ return val
+ if isinstance(val, list):
+ out_l: List[Any] = []
+ cap = 12 if depth == 0 else 8
+ for i, x in enumerate(val):
+ if i >= cap:
+ break
+ out_l.append(_trim(x, depth + 1))
+ return out_l
+ if isinstance(val, dict):
+ out_d: Dict[str, Any] = {}
+ for i, (k, v) in enumerate(val.items()):
+ if i >= 40:
+ break
+ if isinstance(k, str):
+ out_d[k.strip()] = _trim(v, depth + 1)
+ return out_d
+ return str(val)[:max_str]
+
+ shipments = sd.get("shipments")
+ if isinstance(shipments, list) and len(shipments) > max_shipments:
+ sd = {**sd, "shipments": shipments[:max_shipments]}
+ return _trim(sd, 0) or {}
+
+
+SHIPPING_TYPES_CANDIDATE_FILES = [
+ os.path.join(_SHIPPING_DIR, "shipping_types.json"),
+ os.path.join(_SHIPPING_DIR, "shipping_types(1).json"),
+]
+SHIPPING_TYPES_FILE = SHIPPING_TYPES_CANDIDATE_FILES[0]
+PROCESSED_SHIPPING_TYPES_FILE = os.path.join(
+ _SHIPPING_DIR, "shipping_types_processed.json"
+)
+
+
+def resolve_shipping_types_path() -> Optional[str]:
+ for path in SHIPPING_TYPES_CANDIDATE_FILES:
+ if os.path.exists(path):
+ return path
+ return None
+
+
+def _fallback_process_shipping_type_criteria_static(criteria_text: str) -> str:
+ """
+ Быстрый детерминированный fallback для criteria_ai.
+ Это нужно, чтобы отчёты сразу перестали показывать "сырой" длинный список,
+ даже если файл shipping_types_processed.json ещё не заполнен.
+ """
+ if not isinstance(criteria_text, str) or not criteria_text.strip():
+ return ""
+ text = criteria_text.lower()
+
+ label_rules = [
+ ("название клиента" in text, "Клиент"),
+ (("код тн вэд" in text) or ("код тн" in text), "Код ТН ВЭД"),
+ (("инкотерм" in text), "Условия поставки Incoterms"),
+ (("адрес забора" in text) or ("порт отправления" in text) or ("порт погрузки" in text), "Адрес забора груза"),
+ (("адрес доставки" in text) or ("порт назначения" in text) or ("порт выгрузки" in text), "Адрес доставки"),
+ (("стоимость груза" in text), "Стоимость груза"),
+ (("количество грузовых мест" in text) or ("количество мест" in text), "Количество грузовых мест"),
+ (("общий вес" in text), "Вес груза"),
+ (
+ ("габарит" in text)
+ and (
+ "транспортн" in text
+ or "машин" in text
+ or "средств" in text
+ or "кузов" in text
+ or "прицеп" in text
+ or "полуприцеп" in text
+ ),
+ "Габариты машины",
+ ),
+ (("габарит" in text), "Габариты"),
+ (("общий объ" in text) or ("общий объем" in text) or ("объём" in text), "Объём"),
+ (("характер груза" in text) or ("наименование груза" in text), "Характер груза"),
+ (("опасные вещества" in text) or ("опасные свойства" in text), "Опасные свойства"),
+ (("msds" in text) or ("паспорт безопасности" in text), "Требуется MSDS"),
+ (("dgm" in text) or ("dangerous goods management" in text), "Требуется DGM"),
+ (("авторизац" in text), "Разрешение бренда"),
+ (("название бренда" in text) or ("необходимо указать название бренда" in text), "Бренд"),
+ (("экспортная лиценз" in text) or ("экспортной лиценз" in text) or ("экспорт" in text and "лиценз" in text),
+ "Лицензия экспортёра"),
+ (("температурный режим" in text) or ("температур" in text), "Температурный режим"),
+ ]
+
+ labels: List[str] = [label for ok, label in label_rules if ok]
+ deduped = list(dict.fromkeys(labels))
+ return "; ".join(deduped) if deduped else ""
+
+
+# =============================================================================
+# Словарь соответствия технических ключей и понятных названий
+# =============================================================================
+FIELD_LABELS = {
+ "client_name": "Клиент",
+ "incoterms": "Условия поставки Incoterms",
+ "cargo_ready_date": "Дата готовности груза",
+ "pickup_address": "Адрес забора груза",
+ "delivery_address": "Адрес доставки",
+ "weight_kg": "Вес места",
+ "volume_cbm": "Объём места",
+ # добавляем синонимы
+ "pickup_address_alt1": "Адрес забора",
+ "pickup_address_alt2": "Адрес отправки",
+ "pickup_address_alt3": "Место забора",
+
+ "delivery_address_alt1": "Адрес получения",
+ "delivery_address_alt2": "Адрес назначения",
+ "cargo_value": "Стоимость груза",
+ "package_count": "Количество грузовых мест",
+ "total_weight_kg": "Вес груза",
+ "dimensions": "Габариты",
+ "total_volume_cbm": "Объём",
+ "cargo_description": "Характер груза",
+ "delivery_address": "Адрес доставки",
+ "hs_code": "Код ТН ВЭД",
+ "dangerous_goods": "Опасные свойства",
+ "msds_required": "Требуется MSDS",
+ "estimated_cost": "Стоимость перевозки",
+ "estimated_transit_time": "Срок доставки",
+ "dimensions_str": "Габариты",
+ "dangerous_goods_str": "Опасные свойства",
+ "missing_fields": "Необходимая информация",
+ "pickup_address": "Порт погрузки",
+ "delivery_address": "Порт выгрузки",
+ "shipment_type": "Тип перевозки",
+ "container_type": "Тип контейнера",
+ "vehicle_type": "Тип транспорта",
+ "temperature_range": "Температурный режим",
+ "vehicle_dimensions": "Габариты машины",
+ "vehicle_dimensions_str": "Габариты машины",
+ "stackable_with_others": "Штабелирование с другими отправками",
+ "customs_clearance_place_export_rf": "Место таможенного оформления (экспорт РФ)",
+ "shipment_direction": "Направление перевозки",
+ "dangerous_goods_clarification": "Батарейки, газы, жидкости, аэрозоли в грузе (уточнение по категориям)",
+}
+
+# =============================================================================
+# Обратный словарь: понятное название → технический ключ
+# =============================================================================
+LABEL_TO_FIELD = {v: k for k, v in FIELD_LABELS.items()}
+
+
+def _dangerous_goods_unspecified(shipment: Dict) -> bool:
+ dg = shipment.get("dangerous_goods")
+ dg_keys = ("batteries", "gases", "liquids", "dry_ice")
+ if isinstance(dg, dict):
+ for k in dg_keys:
+ if dg.get(k) is True:
+ return False
+ if all(dg.get(k) is False for k in dg_keys):
+ return False
+ if any(dg.get(k) is False for k in dg_keys):
+ return False
+ note = shipment.get("dangerous_goods_note")
+ if isinstance(note, str) and note.strip():
+ return False
+ return True
+
+
+def collect_extra_required_missing(shipment: Dict, st_def: Optional[Dict]) -> List[str]:
+ """Поля из shipping_type.extra_required_fields — в запрос клиенту при отсутствии данных."""
+ if not isinstance(st_def, dict):
+ return []
+ extra = st_def.get("extra_required_fields")
+ if not isinstance(extra, list):
+ return []
+ out: List[str] = []
+ for field in extra:
+ if not isinstance(field, str) or not field.strip():
+ continue
+ field = field.strip()
+ if field == "dangerous_goods_clarification":
+ if _dangerous_goods_unspecified(shipment):
+ out.append(field)
+ continue
+ if field == "stackable_with_others":
+ if shipment.get("stackable_with_others") is None:
+ out.append(field)
+ continue
+ if field == "hs_code":
+ v = shipment.get("hs_code")
+ if v is None or (isinstance(v, str) and not str(v).strip()):
+ out.append(field)
+ continue
+ if field == "vehicle_type":
+ v = shipment.get("vehicle_type")
+ if v is None or (isinstance(v, str) and not str(v).strip()):
+ out.append(field)
+ continue
+ if field == "container_type":
+ v = shipment.get("container_type")
+ if v is None or (isinstance(v, str) and not str(v).strip()):
+ out.append(field)
+ continue
+
+ if field == "customs_clearance_place_export_rf":
+
+ # место таможенного оформления нужно
+ # только при экспорте из РФ
+ direction = shipment.get("shipment_direction")
+
+ if direction != "export":
+ continue
+
+ v = shipment.get("customs_clearance_place_export_rf")
+
+ if v is None or (
+ isinstance(v, str) and not str(v).strip()
+ ):
+ out.append(field)
+
+ continue
+
+ if field == "total_volume_cbm":
+ val = shipment.get("total_volume_cbm")
+ if val is None or val == "":
+ out.append(field)
+ elif isinstance(val, (int, float)) and float(val) <= 0:
+ out.append(field)
+ continue
+ return out
+
+
+def infer_container_load_mode_from_text(text_lower: str) -> Optional[str]:
+ """
+ Оценка FCL/LCL по тексту (контейнерные море/ж/д). None — если противоречиво или нет признаков.
+ """
+ if not text_lower or not isinstance(text_lower, str):
+ return None
+ t = text_lower.lower()
+ lcl = bool(
+ re.search(
+ r"\blcl\b|сборн\w*(\s+груз|\s+контейнер|\s+перевоз|\s+отправк)|"
+ r"группаж|консолидац\w*|consolidat\w*|groupage|"
+ r"less\s*than\s*container|неполн\w*\s+контейнер|"
+ r"дол\w*\s+в\s+контейнер|ко[-\s]?лоад|cfs\b|lcl\s*cargo",
+ t,
+ re.IGNORECASE,
+ )
+ )
+ fcl = bool(
+ re.search(
+ r"\bfcl\b|full\s*container\s*load|"
+ r"цельн\w*\s+контейнер|полн\w*\s+контейнер|"
+ r"отдельн\w*\s+контейнер|exclusive\s*use|"
+ r"soc\s*container|shipper['\u2019]s?\s*own\s*container",
+ t,
+ re.IGNORECASE,
+ )
+ )
+ if lcl and not fcl:
+ return "LCL"
+ if fcl and not lcl:
+ return "FCL"
+ if lcl and fcl:
+ return None
+ if re.search(
+ r"\d{1,3}\s*[x×х*]\s*(20|40|45)\s*['′'`´]?\s*"
+ r"(dc|hc|hq|gp|dv|dry|rf|rh|reefer|ot|fr|tk|tank)?\b",
+ t,
+ re.IGNORECASE,
+ ):
+ return "FCL"
+ return None
+
+
+def _shipping_type_name_implies_load_mode(name: str) -> Optional[str]:
+ if not name:
+ return None
+ n = name.lower()
+ if "(lcl)" in n:
+ return "LCL"
+ if "(fcl)" in n:
+ return "FCL"
+ return None
+
+
+def _is_parallel_multi_cargo_order_table(text: str) -> bool:
+ """
+ Несколько самостоятельных партий в одной таблице (столбцы с разными номерами груза/заказа).
+ Отличается от тендера: разные инвойсы, разные Total, разные требования по DG — это НЕ один shipment.
+ """
+ if not isinstance(text, str) or len(text.strip()) < 80:
+ return False
+ tl = text.lower()
+ if not re.search(r"номер\s+груз", tl):
+ return False
+ troc = re.findall(r"\btrocyps[-_]?\d+\b", text, flags=re.IGNORECASE)
+ uniq_troc = {x.lower().replace("_", "-") for x in troc}
+ if len(uniq_troc) >= 2:
+ if tl.count("total:") >= 2 or len(re.findall(r"\$\s*[\d\s]{4,}", text)) >= 2:
+ return True
+ return False
+
+
+def _strong_invoice_value_mismatch(a: str, b: str) -> bool:
+ """Два ненулевых инвойса в $ с отношением >= 2 — разные партии, не склеивать агрессивно."""
+
+ def _grab_usd(s: str) -> Optional[float]:
+ if not isinstance(s, str):
+ return None
+ m = re.search(r"\$\s*([\d\s.,]+)", s)
+ if not m:
+ return None
+ raw = m.group(1).replace(" ", "").replace("\xa0", "")
+ if raw.count(",") == 1 and raw.count(".") == 0 and len(raw.split(",")[-1]) == 2:
+ raw = raw.replace(",", ".")
+ else:
+ raw = raw.replace(",", "")
+ try:
+ v = float(raw)
+ return v if v > 0 else None
+ except ValueError:
+ return None
+
+ x, y = _grab_usd(a or ""), _grab_usd(b or "")
+ if x is None or y is None:
+ return False
+ lo, hi = (x, y) if x < y else (y, x)
+ return (hi / lo) >= 2.0
+
+
+def _is_tender_multi_origin_context(text: str) -> bool:
+ """
+ Тендер / одна заявка с несколькими адресами забора и одной выгрузкой.
+ Такие письма нельзя дробить на отдельные shipments по каждой точке.
+ """
+ if not isinstance(text, str) or len(text.strip()) < 120:
+ return False
+ if _is_parallel_multi_cargo_order_table(text):
+ return False
+ t = text.lower()
+ score = 0
+ if re.search(r"тендер", t):
+ score += 2
+ if re.search(
+ r"приём\s+предложений|прием\s+предложений|приём\s+оферт|прием\s+оферт",
+ t,
+ ):
+ score += 1
+ if re.search(r"адрес\s+загрузки\s*\d+", t):
+ score += 2
+ n_pickups = len(re.findall(r"адрес\s+загрузки\s*\d+", t))
+ if n_pickups >= 3:
+ score += 3
+ elif n_pickups >= 2:
+ score += 1
+ if re.search(r"\bплеч\w*\b", t) and re.search(r"(?:жд|море|порт|станц)", t):
+ score += 1
+ if re.search(r"\bozon\b", t) and (n_pickups >= 2 or "стм" in t):
+ score += 1
+ return score >= 4
+
+
+# Английские плейсхолдеры в confirmation_template: (Label) -> поле данных
+BRACKET_LABEL_ALIASES_EN: List[tuple[str, str]] = [
+ ("Pickup address", "pickup_address"),
+ ("Delivery address", "delivery_address"),
+ ("Port of loading", "loading_port"),
+ ("Port of discharge", "discharge_port"),
+ ("Cargo weight", "total_weight_kg"),
+ ("Number of packages", "package_count"),
+ ("Volume", "total_volume_cbm"),
+ ("Dimensions", "dimensions_str"),
+ ("Cargo description", "cargo_description"),
+ ("HS code", "hs_code"),
+ ("Dangerous properties", "dangerous_goods_str"),
+ ("MSDS required", "msds_required"),
+ ("Freight cost", "estimated_cost"),
+ ("Transit time", "estimated_transit_time"),
+ ("Shipment type", "shipment_type"),
+ ("Container type", "container_type"),
+ ("Vehicle type", "vehicle_type"),
+ ("Vehicle dimensions", "vehicle_dimensions_str"),
+ ("Temperature regime", "temperature_range"),
+ ("Temperature range", "temperature_range"),
+]
+
+# =============================================================================
+# ОТОБРАЖЕНИЕ КОНТЕЙНЕРОВ (Nxтип без голых 40HC/20DC)
+# =============================================================================
+_CONTAINER_BARE_ONLY = re.compile(
+ r"^\s*(20|40|45)\s*['′'`´]?\s*(?:ft|feet|ф)?\s*"
+ r"(dc|hc|hq|gp|dv|dry|rf|rh|reefer|ref|ot|fr|tk|tank)\s*$",
+ re.IGNORECASE,
+)
+_CONTAINER_NX = re.compile(
+ r"(?\d{1,3})\s*[x×х**]\s*"
+ r"(?P(?:20|40|45)\s*['′'`´]?\s*(?:ft|feet|ф)?\s*"
+ r"(?:dc|hc|hq|gp|dv|dry|rf|rh|reefer|ref|ot|fr|tk|tank)\b)",
+ re.IGNORECASE,
+)
+
+
+def _normalize_container_token(n_str: str, type_part: str) -> str:
+ n = int(n_str)
+ t = re.sub(r"\s+", "", type_part.strip())
+ return f"{n}x{t}"
+
+
+def normalize_container_type_display(raw: Optional[str], *, empty: str = "") -> str:
+ if raw is None:
+ return empty
+ s = str(raw).strip()
+ if not s:
+ return empty
+
+ tokens: List[str] = []
+ for m in _CONTAINER_NX.finditer(s):
+ tokens.append(_normalize_container_token(m.group("n"), m.group("t")))
+
+ if tokens:
+ return ", ".join(tokens)
+
+ kept: List[str] = []
+ for part in re.split(r"[,;]+", s):
+ p = part.strip()
+ if not p:
+ continue
+ if _CONTAINER_BARE_ONLY.match(p):
+ continue
+ if _CONTAINER_NX.search(p):
+ for m in _CONTAINER_NX.finditer(p):
+ kept.append(_normalize_container_token(m.group("n"), m.group("t")))
+ continue
+ if re.match(r"^\d+\s*[x×х**]", p, re.IGNORECASE):
+ kept.append(
+ re.sub(r"\s*([x×х**])\s*", "x", p, count=1, flags=re.IGNORECASE)
+ )
+
+ return ", ".join(kept) if kept else empty
+
+
+# =============================================================================
+# КЛАСС ДЛЯ АВТОЗАПОЛНЕНИЯ ШАБЛОНОВ
+# =============================================================================
+class AutoFillDict(dict):
+ """
+ Словарь, который автоматически возвращает значения для любых ключей.
+ Если ключ не найден — пытается найти по понятному названию (из FIELD_LABELS).
+ Идеально для шаблонов писем без явных {placeholder}.
+ """
+
+ def __init__(self, data: dict, field_labels: dict = None):
+ super().__init__(data)
+ self._data = data
+ self._field_labels = field_labels or FIELD_LABELS
+ self._label_to_field = {v: k for k, v in self._field_labels.items()}
+
+ def __missing__(self, key):
+ # Если ключ есть в оригинальных данных — возвращаем значение
+ if key in self._data:
+ val = self._data[key]
+ return str(val) if val is not None else ""
+
+ # Пытаемся найти по понятному названию (например "Клиент" → "client_name")
+ if key in self._label_to_field:
+ field_key = self._label_to_field[key]
+ if field_key in self._data:
+ val = self._data[field_key]
+ return str(val) if val is not None else ""
+
+ # Если не нашли — возвращаем пустую строку (чтобы не было ошибок)
+ return ""
+
+
+# =============================================================================
+# ФУНКЦИЯ АВТОЗАПОЛНЕНИЯ ШАБЛОНА
+# =============================================================================
+def auto_fill_template(template: str, data: Dict, field_labels: Dict = None) -> str:
+ """
+ Автоматически находит и подставляет значения полей в шаблон.
+ Поддерживает два формата:
+ - {technical_key} — технический ключ
+ - (Понятное название) — русское название в скобках
+ - (English label) — английские подписи из BRACKET_LABEL_ALIASES_EN
+ ❌ НЕ заменяет plain text без скобок!
+ """
+ field_labels = field_labels or FIELD_LABELS
+ label_to_field = {v: k for k, v in field_labels.items()}
+ result = template
+
+ # Создаём словарь для подстановки
+ auto_data = AutoFillDict(data, field_labels)
+
+ # 1. Сначала обрабатываем явные {placeholder}
+ try:
+ result = result.format(**auto_data)
+ except KeyError:
+ pass
+
+ # 2. Обрабатываем (Понятное название) в скобках — русские и английские алиасы
+ merged_pairs = list(label_to_field.items()) + BRACKET_LABEL_ALIASES_EN
+ for label, field_key in merged_pairs:
+ if field_key in data:
+ value = data[field_key]
+ value_str = str(value).strip() if value is not None and str(value).strip() else "не указан"
+
+ # Паттерн для (Вес), (Адрес забора) и т.д.
+ pattern = r'\(' + re.escape(label) + r'\)'
+ result = re.sub(pattern, value_str, result, flags=re.IGNORECASE)
+
+ return result
+
+
+# =============================================================================#
+# ЗАГРУЗКА ТИПОВ ПЕРЕВОЗОК
+# =============================================================================
+def load_shipping_types() -> List[Dict]:
+ """Загружает типы перевозок из JSON-файла с нормализацией ключей и значений."""
+ types_path = resolve_shipping_types_path()
+ if not types_path:
+ logger.warning(
+ "Файл типов перевозок не найден (ожидались shipping_types.json или shipping_types(1).json)"
+ )
+ return []
+
+ try:
+ with open(types_path, "r", encoding="utf-8") as f:
+ raw_types = json.load(f)
+
+ if not isinstance(raw_types, list):
+ logger.error(f"Expected list in shipping_types.json, got {type(raw_types)}")
+ return []
+
+ normalized_types = []
+ for t in raw_types:
+ if not isinstance(t, dict):
+ continue
+
+ normalized = {}
+ for key, value in t.items():
+ clean_key = key.strip().strip('"').strip("'")
+
+ if clean_key == "keywords" and isinstance(value, list):
+ cleaned_keywords = []
+ for kw in value:
+ if isinstance(kw, str):
+ kw_clean = kw.strip().strip('"').strip("'").strip()
+ if not kw_clean:
+ continue
+ if ' ' in kw_clean and len(kw_clean) > 2000:
+ parts = [p.strip().strip('"').strip("'") for p in kw_clean.split() if p.strip()]
+ cleaned_keywords.extend(parts)
+ else:
+ cleaned_keywords.append(kw_clean)
+ normalized[clean_key] = cleaned_keywords
+ elif isinstance(value, str):
+ normalized[clean_key] = value.strip()
+ else:
+ normalized[clean_key] = value
+
+ if "id" not in normalized:
+ normalized["id"] = len(normalized_types) + 1
+
+ normalized_types.append(normalized)
+
+ # Подмешиваем обработанные (ИИ) критерии, если файл существует.
+ processed_map: Dict[str, str] = {}
+ if os.path.exists(PROCESSED_SHIPPING_TYPES_FILE):
+ try:
+ with open(PROCESSED_SHIPPING_TYPES_FILE, "r", encoding="utf-8") as pf:
+ raw_processed = json.load(pf)
+ if isinstance(raw_processed, dict):
+ processed_map = {str(k): str(v) for k, v in raw_processed.items() if v is not None}
+ except Exception as e:
+ logger.warning(f"Failed to load processed shipping criteria: {e}")
+
+ # criteria_ai:
+ # 1) если есть обработанный ИИ-результат в shipping_types_processed.json — используем его
+ # 2) иначе — используем исходный criteria, чтобы отчёт не "терял" пункты
+ for t in normalized_types:
+ name = t.get("name")
+ raw_criteria = t.get("criteria", "") if isinstance(t, dict) else ""
+ processed_val = None
+ if isinstance(name, str):
+ processed_val = processed_map.get(name)
+
+ # Если processed_val выглядит как "старый" короткий список (без \t),
+ # используем исходные критерии, чтобы не терять пункты.
+ if isinstance(processed_val, str) and processed_val.strip() and "\t" in processed_val:
+ t["criteria_ai"] = processed_val
+ else:
+ t["criteria_ai"] = raw_criteria
+
+ logger.info(f"Loaded {len(normalized_types)} shipping types")
+ return normalized_types
+
+ except json.JSONDecodeError as e:
+ logger.error(f"JSON decode error in shipping_types.json: {e}")
+ return []
+ except Exception as e:
+ logger.error(f"Error loading shipping types: {e}")
+ return []
+
+
+# =============================================================================
+# НОРМАЛИЗАЦИЯ КЛЮЧЕЙ СЛОВАРЯ
+# =============================================================================
+def _normalize_dict_keys(data: Any) -> Any:
+ """Рекурсивно нормализует ключи словаря — убирает пробелы и кавычки"""
+ if isinstance(data, dict):
+ return {k.strip().strip('"').strip("'"): _normalize_dict_keys(v) for k, v in data.items()}
+ elif isinstance(data, list):
+ return [_normalize_dict_keys(item) for item in data]
+ else:
+ return data
+
+
+# =============================================================================
+# RAG-ДВИЖОК
+# =============================================================================
+class RAGEngineGemini:
+ """RAG-движок для анализа писем о грузоперевозках и генерации ответов."""
+
+ def __init__(self, api_base_url: str = "http://localhost:8090/v1"):
+ openai_api_key = os.getenv("OPENAI_API_KEY")
+ if not openai_api_key:
+ raise ValueError("OPENAI_API_KEY not found in environment variables")
+
+ self.openai_client = OpenAI(
+ api_key=openai_api_key,
+ base_url=api_base_url
+ )
+ self.sessions: Dict[str, List[Dict]] = {}
+ self.doc_processor = DocumentProcessor()
+ self.shipping_types = load_shipping_types()
+ logger.info(f"RAGEngineGemini initialized with {len(self.shipping_types)} shipping types")
+
+ def reload_shipping_types(self) -> None:
+ """Reload shipping types from disk (used after admin updates)."""
+ self.shipping_types = load_shipping_types()
+ logger.info(f"Shipping types reloaded: {len(self.shipping_types)} types")
+
+ def _fallback_process_shipping_type_criteria(self, criteria_text: str) -> str:
+ """
+ Детерминированная эвристика, если ИИ не доступен.
+ Возвращает короткий список критериев в формате: "A; B; C".
+ """
+ if not isinstance(criteria_text, str) or not criteria_text.strip():
+ return ""
+ text = criteria_text.lower()
+
+ label_rules = [
+ ("название клиента" in text, "Клиент"),
+ (("код тн вэд" in text) or ("код тн" in text), "Код ТН ВЭД"),
+ (("инкотерм" in text), "Условия поставки Incoterms"),
+ (("адрес забора" in text) or ("порт отправления" in text) or ("порт погрузки" in text),
+ "Адрес забора груза"),
+ (("адрес доставки" in text) or ("порт назначения" in text) or ("порт выгрузки" in text), "Адрес доставки"),
+ (("стоимость груза" in text), "Стоимость груза"),
+ (("количество грузовых мест" in text) or ("количество мест" in text), "Количество грузовых мест"),
+ (("общий вес" in text), "Вес груза"),
+ (
+ ("габарит" in text)
+ and (
+ "транспортн" in text
+ or "машин" in text
+ or "средств" in text
+ or "кузов" in text
+ or "прицеп" in text
+ or "полуприцеп" in text
+ ),
+ "Габариты машины",
+ ),
+ (("габарит" in text), "Габариты"),
+ (("общий объ" in text) or ("общий объем" in text) or ("объём" in criteria_text.lower()), "Объём"),
+ (("характер груза" in text) or ("наименование груза" in text), "Характер груза"),
+ (("опасные вещества" in text) or ("опасные свойства" in text) or ("batter" in text), "Опасные свойства"),
+ (("msds" in text) or ("паспорт безопасности" in text), "Требуется MSDS"),
+ (("dgm" in text) or ("dangerous goods management" in text), "Требуется DGM"),
+ (("авторизац" in text), "Разрешение бренда"),
+ (("необходимо указать название бренда" in text) or ("название бренда" in text), "Бренд"),
+ (("экспортн" in text) and ("лиценз" in text) or ("экспортная лиценз" in text) or (
+ "экспортной лиценз" in text), "Лицензия экспортёра"),
+ (("температурный режим" in text) or ("температур" in text), "Температурный режим"),
+ ]
+
+ labels: List[str] = [label for ok, label in label_rules if ok]
+ # убираем дубликаты, сохраняя порядок
+ deduped = list(dict.fromkeys(labels))
+ if not deduped:
+ return ""
+ return "; ".join(deduped)
+
+ def _enrich_special_requirements(self, shipment: Dict, sources: List[Dict]) -> None:
+ if not isinstance(shipment, dict):
+ return
+ raw_text = self._collect_shipment_source_text(shipment, sources)
+ if not raw_text.strip():
+ return
+
+ # Ключевые паттерны: обязательно содержат слова штрафа / срока / транзита
+ patterns = [
+ # "транзитный срок до 25 дней под зеркальные штрафы 250 USD"
+ r"(?:транзитн\w*\s+)?срок\w*\s+(?:до\s+)?\d+\s*(?:дн\w*|day\w*).{0,60}?(?:зеркальн\w*\s+)?штраф\w*\s*\d+\s*(?:USD|доллар\w*|руб\w*|EUR)?",
+ # "зеркальные штрафы 250 USD"
+ r"зеркальн\w*\s+штраф\w*\s*\d+\s*(?:USD|доллар\w*|руб\w*|EUR)?",
+ # "штраф за просрочку 100 USD в день"
+ r"(?:штраф|penalty|неустойк\w*|пени)\s*(?:за\s+)?(?:просрочк\w*|опоздани\w*|срыв\s+срок\w*).{0,100}?\d+\s*(?:USD|доллар\w*|руб\w*|EUR|дн\w*)",
+ # "срок доставки до 20 дней, иначе штраф 500"
+ r"срок\s+доставк\w*\s*(?:до\s+)?\d+\s*(?:дн\w*|day\w*).*?(?:штраф|неустойк|пеня|penalty)",
+ # "срок доставки 25 дней" / "delivery time 25 days"
+ r"(?:срок|транзитн\w*\s+срок)\s+доставк\w*\s*(?:до\s+)?\d+\s*(?:дн\w*|day\w*)",
+ r"(?:delivery|transit)\s*time\s*(?:up\s*to\s*)?\d+\s*day\w*",
+ # "liquidated damages 1000 USD"
+ r"(?:liquidated\s+damages|ld\s*clause).{0,100}?\d+\s*(?:USD|доллар|руб|EUR)",
+ # "жёсткий срок до 15.05.2025"
+ r"доставк\w*\s+(?:строго|жёстко|обязательно)\s+(?:до|к|не\s+позднее)\s+\d{1,2}\.\d{1,2}(?:\.\d{2,4})?",
+ ]
+
+ found_phrases: List[str] = []
+ for pat in patterns:
+ for m in re.finditer(pat, raw_text, re.IGNORECASE | re.DOTALL):
+ phrase = re.sub(r'\s+', ' ', m.group(0).strip())
+ if phrase not in found_phrases:
+ found_phrases.append(phrase)
+
+ if not found_phrases:
+ return
+
+ # Объединяем с уже существующим значением, избегая повторов
+ existing = (shipment.get("special_transport_requirements") or "").strip()
+ if existing:
+ new_parts = [p for p in found_phrases if p.lower() not in existing.lower()]
+ if new_parts:
+ shipment["special_transport_requirements"] = existing + " | " + " | ".join(new_parts)
+ else:
+ shipment["special_transport_requirements"] = " | ".join(found_phrases)
+
+ # Расширяем доп.сервисы: сроки доставки и штрафные условия должны быть видны отдельным пунктом.
+ extras = shipment.get("additional_services")
+ if not isinstance(extras, list):
+ extras = []
+ seen = {str(x).strip().lower() for x in extras if str(x).strip()}
+ for phrase in found_phrases:
+ normalized = phrase.strip()
+ if not normalized:
+ continue
+ key = normalized.lower()
+ if key not in seen:
+ extras.append(normalized)
+ seen.add(key)
+ shipment["additional_services"] = extras
+
+ def process_shipping_type_criteria(self, criteria_text: str) -> str:
+ """
+ Прогоняет criteria из shipping_type через ИИ и возвращает ОЧИЩЕННУЮ версию criteria
+ (с сохранением нумерованного формата), чтобы отчёт показывал ровно пункты из criteria.
+ """
+
+ def _normalize_criteria_text(text: str) -> str:
+ if not isinstance(text, str):
+ return ""
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
+ normalized: List[str] = []
+ for idx, ln in enumerate(lines, start=1):
+ m = re.match(r"^\s*(\d+)[\.\)\-]?\s*(.*)$", ln)
+ if m:
+ num = m.group(1).strip()
+ body = m.group(2).strip()
+ if body:
+ normalized.append(f"{num}\t{body}")
+ else:
+ body = ln
+ normalized.append(f"{idx}\t{body}")
+ return "\n".join(normalized)
+
+ fallback = _normalize_criteria_text(criteria_text.strip()) if isinstance(criteria_text, str) else ""
+ if not isinstance(criteria_text, str) or not criteria_text.strip():
+ return ""
+
+ system_prompt = (
+ "Ты инженер по логистике и формированию отчётов. "
+ "Тебе дан текст критерием из поля shipping_type (обычно нумерованный список вида: \"1\\t...\\n2\\t...\"). "
+ "Твоя задача: сделать формулировки критериев более аккуратными и единообразными, но без потери смысла. "
+ "Нельзя удалять пункты или объединять их. "
+ "Верни строго нумерованный список в формате: <номер>\\t<критерий>, каждый пункт с новой строки. "
+ "Возвращай ответ строго в JSON-формате: {\"processed_criteria\": \"...\"}."
+ )
+ user_prompt = f"Исходные критерии shipping_type:\n{criteria_text}"
+
+ try:
+ response = self.openai_client.chat.completions.create(
+ model="deepseek-v4-flash",
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_prompt},
+ ],
+ temperature=0,
+ # max_tokens=250,
+ # extra_body={
+ # "thinking": False
+ # }
+ )
+ answer_text = response.choices[0].message.content or ""
+ finish_reason = response.choices[0].finish_reason
+
+ logger.info(f"Finish reason: {finish_reason}")
+
+ try:
+ logger.info(f"Usage: {response.usage}")
+ except Exception:
+ pass
+ # ожидаем {"processed_criteria":"..."}
+ parsed = self._parse_json_response(answer_text)
+ processed = parsed.get("processed_criteria")
+ if isinstance(processed, str):
+ processed = _normalize_criteria_text(processed.strip())
+ if processed:
+ return processed
+ except Exception as e:
+ logger.warning(f"AI processing criteria failed, fallback to original criteria: {e}")
+
+ return fallback
+
+ async def process_outlook_emails(self, emails: List[Dict], session_id: Optional[str] = None) -> str:
+ try:
+ if session_id is None or session_id not in self.sessions:
+ session_id = hashlib.md5(f"{time.time()}{str(emails)}".encode()).hexdigest()[:16]
+ self.sessions[session_id] = []
+ logger.info(f"Created new session {session_id}")
+
+ for email in emails:
+ email_text, attachments = self._extract_email_content(email)
+ shipping_type = self._detect_shipping_type_from_email(email_text)
+ if shipping_type:
+ logger.info(f"Detected shipping type: {shipping_type}")
+
+ self.sessions[session_id].append({
+ "content": email_text,
+ "metadata": {
+ "subject": email.get("subject", ""),
+ "sender": email.get("sender", ""),
+ "sender_name": email.get("senderName", ""),
+ "received_time": str(email.get("receivedTime", "")),
+ "email_id": email.get("id"),
+ "to": email.get("to", ""),
+ "cc": email.get("cc", "")
+ },
+ "attachments": attachments,
+ "shipping_type": shipping_type
+ })
+ logger.info(f"Indexed email {email.get('id')} with session_id {session_id}")
+
+ return session_id
+
+ except Exception as e:
+ logger.error(f"Error processing Outlook emails: {e}", exc_info=True)
+ raise
+
+ def _text_requests_all_shipping_types(self, text: str) -> bool:
+ """
+ Клиент просит варианты по всем способам доставки (а не один конкретный тип).
+ """
+ if not text or not isinstance(text, str):
+ return False
+ t = text.lower()
+ patterns = [
+ r"все\s+возможн\w*\s+тип\w*\s+(?:доставк|перевозк|отправк)",
+ r"все\s+тип\w*\s+(?:доставк|перевозк|отправк)",
+ r"все\s+вариант\w*\s+(?:доставк|перевозк|доставки|перевозки)",
+ r"все\s+способ\w*\s+доставк",
+ r"по\s+всем\s+тип\w*\s+(?:доставк|перевозк)",
+ r"по\s+всем\s+способ\w*\s+доставк",
+ r"люб\w*\s+(?:из\s+)?тип\w*\s+доставк",
+ r"люб\w*\s+способ\w*\s+доставк",
+ r"люб\w*\s+вариант\w*\s+доставк",
+ r"все\s+канал\w*\s+доставк",
+ r"все\s+модальност\w*",
+ r"люб\w*\s+вид\w*\s+доставк",
+ r"all\s+(?:possible\s+)?types?\s+of\s+(?:delivery|shipping|transport|freight)",
+ r"all\s+(?:shipping|delivery)\s+options?",
+ r"any\s+(?:type\s+of\s+)?(?:delivery|shipping|freight)",
+ r"every\s+(?:shipping|delivery)\s+(?:type|option|mode)",
+ ]
+ return any(re.search(p, t, re.IGNORECASE) for p in patterns)
+
+ def _detect_shipping_type_from_email(self, email_text: str) -> Optional[str]:
+ """Тип перевозки выбирает основной LLM; при индексации писем не подставляем тип по ключевым словам."""
+ return None
+
+ def _extract_emails_from_text(self, raw: Any) -> List[str]:
+ if not raw:
+ return []
+ text = str(raw).lower()
+ return list(dict.fromkeys(re.findall(r"[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}", text)))
+
+ def _recipients_for_shipment(self, shipment: Dict, sources: List[Dict]) -> set[str]:
+ if not isinstance(shipment, dict) or not isinstance(sources, list):
+ return set()
+ email_ids = set(shipment.get("ID_emails") or [])
+ if not email_ids:
+ return set()
+ recipients: set[str] = set()
+ for src in sources:
+ if not isinstance(src, dict):
+ continue
+ if src.get("id") not in email_ids:
+ continue
+ recipients.update(self._extract_emails_from_text(src.get("to")))
+ recipients.update(self._extract_emails_from_text(src.get("cc")))
+ return recipients
+
+ def _shipping_types_matching_recipients(self, recipients: set[str]) -> List[Dict]:
+ if not recipients or not self.shipping_types:
+ return []
+ matches: List[Dict] = []
+ for st in self.shipping_types:
+ if not isinstance(st, dict):
+ continue
+ raw = st.get("employee_email") or ""
+ bound_emails = set(self._extract_emails_from_text(raw))
+ if bound_emails and (bound_emails & recipients):
+ matches.append(st)
+ return matches
+
+ def _pick_recipient_shipping_type(
+ self, recipients: set[str], combined_text: str
+ ) -> Optional[Dict]:
+ """
+ Если на ящик заведено несколько типов — без скоринга по тексту: первый совпавший
+ в порядке записей в shipping_types (combined_text зарезервирован под будущие правила).
+ """
+ _ = combined_text
+ matches = self._shipping_types_matching_recipients(recipients)
+ if not matches:
+ return None
+ return matches[0]
+
+ def _shipping_type_by_recipient(self, recipients: set[str]) -> Optional[Dict]:
+ m = self._shipping_types_matching_recipients(recipients)
+ return m[0] if m else None
+
+ def _contains_russia(self, text: str) -> bool:
+ if not text:
+ return False
+
+ text = text.lower()
+
+ patterns = [
+ r"\bросси",
+ r"\brussia\b",
+ r"\bрф\b",
+ r"\bmoscow\b",
+ r"\bмоскв",
+ r"\bsaint[\s\-]?petersburg\b",
+ r"\bсанкт[\s\-]?петербург",
+ ]
+
+ return any(
+ re.search(p, text, re.IGNORECASE)
+ for p in patterns
+ )
+
+ def _detect_shipment_direction(self, shipment: Dict) -> str:
+ pickup = str(shipment.get("pickup_address") or "")
+ delivery = str(shipment.get("delivery_address") or "")
+ loading = str(shipment.get("loading_port") or "")
+ discharge = str(shipment.get("discharge_port") or "")
+
+ origin = " ".join([pickup, loading]).strip()
+ destination = " ".join([delivery, discharge]).strip()
+
+ origin_ru = self._contains_russia(origin)
+ destination_ru = self._contains_russia(destination)
+
+ # экспорт из РФ
+ if origin_ru and not destination_ru:
+ return "export"
+
+ # импорт в РФ
+ if not origin_ru and destination_ru:
+ return "import"
+
+ # внутрироссийская перевозка
+ if origin_ru and destination_ru:
+ return "domestic"
+
+ return "unknown"
+
+ def normalize_shipment(self, shipment: dict, all_shipments: list | None = None) -> dict:
+ """
+ Унифицирует и исправляет данные shipment:
+ - Китай → обязательная авторизация бренда
+ - None вместо ложных false
+ - корректная логика MSDS / DGM (включая "не предоставляет")
+ - перенос общих полей (штабелирование и т.д.)
+ - таможня → только место
+ """
+
+ if not isinstance(shipment, dict):
+ return shipment
+
+ # =========================================================
+ # 1. Утилиты
+ # =========================================================
+ def is_empty(val):
+ return val is None or (isinstance(val, str) and not val.strip())
+
+ # =========================================================
+ # 2. Китай / бренд → авторизационное письмо
+ # =========================================================
+ pickup = shipment.get("pickup_address") or ""
+ loading = shipment.get("loading_port") or ""
+ delivery = shipment.get("delivery_address") or ""
+ cargo_desc = shipment.get("cargo_description") or ""
+ brand = shipment.get("brand_name")
+
+ shipment["shipment_direction"] = self._detect_shipment_direction(shipment)
+
+ loc_for_china = " ".join(str(x) for x in (pickup, loading, delivery, cargo_desc) if x)
+ if (
+ brand
+ and isinstance(brand, str)
+ and brand.strip()
+ and self._text_mentions_china_context(loc_for_china)
+ ):
+ shipment["brand_authorization_letter"] = True
+
+ # =========================================================
+ # 3. Батарейки (важно: None != False)
+ # =========================================================
+ dg = shipment.get("dangerous_goods")
+
+ if isinstance(dg, dict):
+ # если вообще ничего не указано (нет ни True, ни False) → всё в None
+ # Важно: явные False (non-DG) сохраняем, не затираем.
+ has_true = any(v is True for v in dg.values())
+ has_false = any(v is False for v in dg.values())
+ if not has_true and not has_false:
+ shipment["dangerous_goods"] = {
+ "batteries": None,
+ "gases": None,
+ "liquids": None,
+ "dry_ice": None
+ }
+
+ # batteries_packed_separately
+ if shipment.get("batteries_packed_separately") is False:
+ # если батарейки вообще не указаны → должно быть None
+ if not isinstance(dg, dict) or dg.get("batteries") is not True:
+ shipment["batteries_packed_separately"] = None
+
+ # =========================================================
+ # 4. MSDS / DGM логика
+ # =========================================================
+ docs = shipment.get("documents_found", {}) or {}
+
+ # MSDS
+ if shipment.get("msds_required"):
+ if not docs.get("msds"):
+ shipment["msds_status"] = "not_provided"
+ else:
+ shipment["msds_status"] = "provided"
+ else:
+ shipment["msds_status"] = None
+
+ # DGM
+ if shipment.get("dgm_report_required"):
+ if not docs.get("dgm"):
+ shipment["dgm_status"] = "not_provided"
+ else:
+ shipment["dgm_status"] = "provided"
+ else:
+ shipment["dgm_status"] = None
+
+ # =========================================================
+ # 5. Таможня → только место (убираем "нужно/не нужно")
+ # =========================================================
+ place = shipment.get("customs_clearance_place_export_rf")
+
+ if is_empty(place):
+ shipment["customs_clearance_place_export_rf"] = None
+ else:
+ shipment["customs_clearance_required"] = None # отключаем булевую логику
+
+ # =========================================================
+ # 6. Контейнеры (морская логика)
+ # =========================================================
+ CONTAINER_TYPES_MAP = {
+ # 20 футов стандарт (20DC / 20GP)
+ "20DC": [
+ "20dc", "20 dc", "20gp", "20 gp",
+ "20'", "20 ft", "20фт", "20 фут", "20 футов",
+ "контейнер 20 фут", "контейнер 20 футов",
+ "20 футовый контейнер", "20-футовый контейнер",
+ "20 фут / контейнер 20 футов",
+ "20f", "20 фут контейнер"
+ ],
+
+ # 40 футов стандарт (40DC / 40GP)
+ "40DC": [
+ "40dc", "40 dc", "40gp", "40 gp",
+ "40'", "40 ft", "40фт", "40 фут", "40 футов",
+ "контейнер 40 фут", "контейнер 40 футов",
+ "40 футовый контейнер", "40-футовый контейнер",
+ "40f", "40 фут контейнер"
+ ],
+
+ # 40 футов High Cube
+ "40HC": [
+ "40hc", "40 hc", "40hq", "40 hq",
+ "high cube", "hc",
+ "40 фут hc", "40 футов hc",
+ "40 футов высокий", "высокий контейнер 40",
+ "40 футов high cube",
+ "40hc контейнер"
+ ],
+
+ # 45 футов High Cube
+ "45HC": [
+ "45hc", "45 hc",
+ "45'", "45 ft", "45 фут", "45 футов",
+ "контейнер 45 фут", "45 футовый контейнер",
+ "45 футов high cube"
+ ],
+
+ # LCL / сборный груз
+ "LCL": [
+ "lcl", "сборный", "сборный груз",
+ "частичная загрузка", "менее контейнера",
+ "less than container load"
+ ],
+
+ # FCL (полный контейнер)
+ "FCL": [
+ "fcl", "полный контейнер", "целый контейнер",
+ "full container load"
+ ]
+ }
+
+ def detect_container_type(text: str) -> str | None:
+ text = text.lower()
+
+ for normalized, variants in CONTAINER_TYPES_MAP.items():
+ for variant in variants:
+ if variant in text:
+ return normalized
+
+ return None
+
+ container = shipment.get("container_type")
+
+ if not container:
+ # не завязываемся на FCL/LCL — просто оставляем None
+ shipment["container_type"] = None
+
+ # =========================================================
+ # 7. Штабелирование — не копируем между разными перевозками в одном отчёте
+ # =========================================================
+ if (
+ all_shipments
+ and isinstance(all_shipments, list)
+ and len(all_shipments) == 1
+ ):
+ base = all_shipments[0]
+
+ for field in ["stackable_with_others", "stackable_among_themselves"]:
+ if shipment.get(field) is None:
+ shipment[field] = base.get(field)
+
+ # =========================================================
+ # 8. Габариты → гарантируем список
+ # =========================================================
+ dims = shipment.get("dimensions")
+
+ if not isinstance(dims, list):
+ shipment["dimensions"] = []
+ else:
+ # Нормализуем ключи/единицы и убираем неполные варианты.
+ # В некоторых ответах ИИ размеры приходят как length/width/height или в *_mm.
+ def _to_float(v: Any) -> Optional[float]:
+ if isinstance(v, (int, float)):
+ return float(v)
+ if isinstance(v, str):
+ s = v.strip().replace(" ", "")
+ s = s.replace(",", ".")
+ try:
+ return float(s)
+ except Exception:
+ return None
+ return None
+
+ def _normalize_one_dim(d: Any) -> Optional[Dict[str, float]]:
+ if not isinstance(d, dict):
+ return None
+
+ # Если LLM уже заполнил именно *_cm поля, считаем их первичным источником.
+ # Это защищает от двойной конвертации (например 107 см -> 10.7 см при unit=mm).
+ has_explicit_cm_keys = any(
+ d.get(k) is not None for k in ("length_cm", "width_cm", "height_cm")
+ )
+ l_cm = _to_float(d.get("length_cm"))
+ w_cm = _to_float(d.get("width_cm"))
+ h_cm = _to_float(d.get("height_cm"))
+
+
+ # --- новая логика конвертации единиц (не теряем частичные размеры) ---
+ # Определяем единицу измерения
+ unit_key = _normalize_dimension_unit_field(
+ d.get("dimension_unit") or d.get("unit") or d.get("dimensions_unit")
+ )
+
+ # Если есть явные *_cm поля, конвертация не требуется
+ # (защита от двойной конвертации, как и раньше)
+ has_explicit_cm_keys = any(
+ d.get(k) is not None for k in ("length_cm", "width_cm", "height_cm")
+ )
+
+ def _to_cm(value: Optional[float], unit: Optional[str]) -> Optional[float]:
+ if value is None:
+ return None
+ if unit == "mm":
+ return value / 10.0
+ elif unit == "m":
+ return value * 100.0
+ # cm или неизвестный – оставляем как есть
+ return value
+
+ # Если не было явных *_cm, конвертируем согласно единице измерения
+ if not has_explicit_cm_keys:
+ l_cm = _to_cm(l_cm, unit_key)
+ w_cm = _to_cm(w_cm, unit_key)
+ h_cm = _to_cm(h_cm, unit_key)
+
+ # Дополнительная эвристика: если значения аномально большие (>1000),
+ # возможно, это всё-таки миллиметры (только если unit_key не задан)
+ if unit_key is None:
+ existing = [v for v in (l_cm, w_cm, h_cm) if v is not None]
+ if existing and max(existing) >= 1000:
+ l_cm = l_cm / 10.0 if l_cm is not None else None
+ w_cm = w_cm / 10.0 if w_cm is not None else None
+ h_cm = h_cm / 10.0 if h_cm is not None else None
+
+ # Финальная валидация (не допускаем нули/отрицательные).
+ for value in (l_cm, w_cm, h_cm):
+ if value is not None and value <= 0:
+ logger.info(
+ f"DIM DROP: l={l_cm}, w={w_cm}, h={h_cm}"
+ )
+ return None
+
+ weight_kg = _to_float(d.get("weight_kg"))
+ volume_cbm = _to_float(d.get("volume_cbm"))
+ if volume_cbm is None:
+ volume_cbm = _to_float(d.get("volume"))
+
+ if weight_kg is None:
+ weight_kg = _to_float(d.get("weight"))
+
+ normalized = {}
+
+ if l_cm is not None:
+ normalized["length_cm"] = round(l_cm, 4)
+
+ if w_cm is not None:
+ normalized["width_cm"] = round(w_cm, 4)
+
+ if h_cm is not None:
+ normalized["height_cm"] = round(h_cm, 4)
+
+ # Добавляем вес места только если он есть
+ if weight_kg is not None and weight_kg > 0:
+ normalized["weight_kg"] = round(weight_kg, 4)
+
+ # Добавляем объём места только если он есть
+ if volume_cbm is not None and volume_cbm > 0:
+ normalized["volume_cbm"] = round(volume_cbm, 6)
+
+ return normalized
+
+ # Дедупликация по (Д,Ш,В)
+ cleaned: List[Dict[str, float]] = []
+
+ for d in dims:
+
+ logger.info(f"RAW DIM: {d}")
+
+ norm = _normalize_one_dim(d)
+
+ logger.info(f"NORMALIZED DIM: {norm}")
+
+ if not norm:
+ logger.info("DIM SKIPPED")
+ continue
+
+ cleaned.append(norm)
+
+ shipment["dimensions"] = cleaned
+ logger.info(
+ f"FINAL DIMENSIONS: {shipment['dimensions']}"
+ )
+ # =========================================================
+ # 8b. Габариты ТС (машина/кузов) — отдельно от груза
+ # =========================================================
+ vdims = shipment.get("vehicle_dimensions")
+ if not isinstance(vdims, list):
+ shipment["vehicle_dimensions"] = []
+ else:
+ def _to_float_v(v: Any) -> Optional[float]:
+ if isinstance(v, (int, float)):
+ return float(v)
+ if isinstance(v, str):
+ s = v.strip().replace(" ", "").replace(",", ".")
+ try:
+ return float(s)
+ except Exception:
+ return None
+ return None
+
+ def _normalize_vehicle_dim(d: Any) -> Optional[Dict[str, float]]:
+ if not isinstance(d, dict):
+ return None
+ lm = _to_float_v(d.get("length_m"))
+ wm = _to_float_v(d.get("width_m"))
+ hm = _to_float_v(d.get("height_m"))
+ if lm is not None and wm is not None and hm is not None:
+ l_cm, w_cm, h_cm = lm * 100.0, wm * 100.0, hm * 100.0
+ else:
+ l_cm = _to_float_v(d.get("length_cm"))
+ w_cm = _to_float_v(d.get("width_cm"))
+ h_cm = _to_float_v(d.get("height_cm"))
+ used_mm = False
+ if l_cm is None and d.get("length_mm") is not None:
+ l_mm = _to_float_v(d.get("length_mm"))
+ if l_mm is not None:
+ l_cm = l_mm / 10.0
+ used_mm = True
+ if w_cm is None and d.get("width_mm") is not None:
+ w_mm = _to_float_v(d.get("width_mm"))
+ if w_mm is not None:
+ w_cm = w_mm / 10.0
+ used_mm = True
+ if h_cm is None and d.get("height_mm") is not None:
+ h_mm = _to_float_v(d.get("height_mm"))
+ if h_mm is not None:
+ h_cm = h_mm / 10.0
+ used_mm = True
+ if l_cm is None and d.get("length") is not None:
+ l_cm = _to_float_v(d.get("length"))
+ if w_cm is None and d.get("width") is not None:
+ w_cm = _to_float_v(d.get("width"))
+ if h_cm is None and d.get("height") is not None:
+ h_cm = _to_float_v(d.get("height"))
+ if l_cm is None and w_cm is None and h_cm is None:
+ logger.info(
+ f"DIM DROP: l={l_cm}, w={w_cm}, h={h_cm}"
+ )
+ return None
+ # Не применяем эвристику мм↔см по max>120: у ТС длина часто >120 см.
+ if not used_mm and max(l_cm, w_cm, h_cm) <= 30 and min(l_cm, w_cm, h_cm) >= 0.3:
+ l_cm *= 100.0
+ w_cm *= 100.0
+ h_cm *= 100.0
+ for value in (l_cm, w_cm, h_cm):
+ if value is not None and value <= 0:
+ return None
+ return {
+ "length_cm": round(l_cm, 4),
+ "width_cm": round(w_cm, 4),
+ "height_cm": round(h_cm, 4),
+ }
+
+ cleaned_v: List[Dict[str, float]] = []
+ seen_v: set[tuple[float, float, float]] = set()
+ for d in vdims:
+ norm = _normalize_vehicle_dim(d)
+ if not norm:
+ continue
+ key = (
+ round(norm["length_cm"], 1),
+ round(norm["width_cm"], 1),
+ round(norm["height_cm"], 1),
+ )
+ if key in seen_v:
+ continue
+ seen_v.add(key)
+ cleaned_v.append(norm)
+ shipment["vehicle_dimensions"] = cleaned_v
+
+ # =========================================================
+ # 9. Таможка не упоминается → None (не True!)
+ # =========================================================
+ if shipment.get("customs_clearance_required") is True:
+ if is_empty(place):
+ shipment["customs_clearance_required"] = None
+
+ # =========================================================
+ # 10. Доп. защита от "галлюцинаций"
+ # =========================================================
+ for key in [
+ "msds_required",
+ "dgm_report_required",
+ "brand_authorization_letter",
+ "exporter_has_export_license"
+ ]:
+ if shipment.get(key) not in [True, False]:
+ shipment[key] = None
+
+ return shipment
+
+ def _normalize_text_key(self, value: Any) -> str:
+ if not isinstance(value, str):
+ return ""
+ # Нормализуем для группировки: регистр/пробелы/знаки препинания.
+ return re.sub(r"[\s,.;:()\-_/]+", " ", value.lower()).strip()
+
+ def _merge_bool_values(self, a: Any, b: Any) -> Any:
+ # Не додумываем: если есть конфликт true/false -> None.
+ if a is None:
+ return b
+ if b is None:
+ return a
+ if bool(a) == bool(b):
+ return bool(a)
+ return None
+
+ def _cargo_ready_date_parts(self, val: Any) -> List[str]:
+ if val is None:
+ return []
+ if isinstance(val, list):
+ return [str(x).strip() for x in val if x is not None and str(x).strip()]
+ s = str(val).strip()
+ if not s:
+ return []
+ return [p.strip() for p in re.split(r"\s*,\s*|\s*;\s*", s) if p.strip()]
+
+ def _merge_cargo_ready_date(self, a: Any, b: Any) -> str:
+ merged = list(dict.fromkeys(self._cargo_ready_date_parts(a) + self._cargo_ready_date_parts(b)))
+ return ", ".join(merged)
+
+ def _merge_shipment_into(self, base: Dict, s: Dict) -> None:
+ """Сливает поля перевозки s в base (изменяет base)."""
+ if not isinstance(base, dict) or not isinstance(s, dict):
+ return
+
+ ids = list(dict.fromkeys((base.get("ID_emails") or []) + (s.get("ID_emails") or [])))
+ base["ID_emails"] = ids
+
+ pickups = []
+ for p in [base.get("pickup_address"), s.get("pickup_address")]:
+ if isinstance(p, str) and p.strip():
+ pickups.extend([x.strip() for x in p.split(" | ") if x.strip()])
+ unique_pickups_list = list(dict.fromkeys(pickups))
+ base["pickup_address"] = " | ".join(unique_pickups_list) if unique_pickups_list else ""
+
+ for num_key in ["package_count", "total_weight_kg", "total_volume_cbm"]:
+ a = base.get(num_key)
+ b = s.get(num_key)
+ if isinstance(a, (int, float)) and isinstance(b, (int, float)):
+ base[num_key] = a + b
+ elif a is None and isinstance(b, (int, float)):
+ base[num_key] = b
+
+ base_dims = base.get("dimensions") if isinstance(base.get("dimensions"), list) else []
+ cur_dims = s.get("dimensions") if isinstance(s.get("dimensions"), list) else []
+ base["dimensions"] = base_dims + cur_dims
+
+ base_vd = base.get("vehicle_dimensions") if isinstance(base.get("vehicle_dimensions"), list) else []
+ cur_vd = s.get("vehicle_dimensions") if isinstance(s.get("vehicle_dimensions"), list) else []
+ base["vehicle_dimensions"] = base_vd + cur_vd
+
+ for list_key in ["additional_services", "shipping_options", "shipping_type_candidates"]:
+ base_list = base.get(list_key) if isinstance(base.get(list_key), list) else []
+ cur_list = s.get(list_key) if isinstance(s.get(list_key), list) else []
+ merged = []
+ seen = set()
+ for item in base_list + cur_list:
+ marker = json.dumps(item, ensure_ascii=False, sort_keys=True, default=str)
+ if marker in seen:
+ continue
+ seen.add(marker)
+ merged.append(item)
+ base[list_key] = merged
+
+ def _name_list_union(a_raw: Any, b_raw: Any) -> List[str]:
+ def _parts(x: Any) -> List[str]:
+ if x is None:
+ return []
+ if isinstance(x, str):
+ return [x.strip()] if x.strip() else []
+ if isinstance(x, list):
+ return [str(z).strip() for z in x if isinstance(z, str) and str(z).strip()]
+ return []
+
+ return list(dict.fromkeys(_parts(a_raw) + _parts(b_raw)))
+
+ r_merged = _name_list_union(
+ base.get("requested_shipping_type_names"), s.get("requested_shipping_type_names")
+ )
+ if r_merged:
+ base["requested_shipping_type_names"] = r_merged
+
+ dg_keys = ["batteries", "gases", "liquids", "dry_ice"]
+ dg_a = base.get("dangerous_goods") if isinstance(base.get("dangerous_goods"), dict) else {}
+ dg_b = s.get("dangerous_goods") if isinstance(s.get("dangerous_goods"), dict) else {}
+ dg_merged = {}
+ for k in dg_keys:
+ va = dg_a.get(k)
+ vb = dg_b.get(k)
+ if va is True or vb is True:
+ dg_merged[k] = True
+ elif va is False or vb is False:
+ dg_merged[k] = False
+ else:
+ dg_merged[k] = None
+ base["dangerous_goods"] = dg_merged
+
+ for bool_key in [
+ "stackable_with_others", "stackable_among_themselves",
+ "msds_required", "batteries_packed_separately", "dgm_report_required",
+ "brand_authorization_letter", "document_replacement_needed",
+ "transshipment_with_third_country",
+ "exporter_has_export_license", "customs_clearance_required",
+ "fumigation_on_wooden_packaging"
+ ]:
+ base[bool_key] = self._merge_bool_values(base.get(bool_key), s.get(bool_key))
+
+ cr_merged = self._merge_cargo_ready_date(base.get("cargo_ready_date"), s.get("cargo_ready_date"))
+ base["cargo_ready_date"] = cr_merged
+
+ for text_key in [
+ "client_name", "incoterms", "cargo_value", "cargo_description", "hs_code",
+ "brand_name", "dangerous_goods_note", "arrival_expediting_responsibility",
+ "special_transport_requirements",
+ "customs_clearance_place_export_rf", "shipping_type", "criteria", "criteria_preview",
+ "delivery_address", "loading_port", "discharge_port",
+ "container_type", "vehicle_type",
+ ]:
+ a = (base.get(text_key) or "").strip() if isinstance(base.get(text_key), str) else ""
+ b = (s.get(text_key) or "").strip() if isinstance(s.get(text_key), str) else ""
+ if not a and b:
+ base[text_key] = b
+ elif a and b and a != b:
+ vals = list(dict.fromkeys([x.strip() for x in (a + " | " + b).split(" | ") if x.strip()]))
+ base[text_key] = " | ".join(vals)
+
+ def _collapse_shipments_single_source(self, shipments: List[Dict], sources: List[Dict]) -> List[Dict]:
+ """
+ Если анализируется одно письмо, а модель вернула несколько shipments — мы не обязаны
+ склеивать всё в одну запись.
+ Склеиваем только те shipments, которые выглядят как одна и та же партия груза (нет конфликтов
+ по смысловым якорям: бренд/HS/описание груза/даты готовности/документы и т.д.).
+ """
+ if not isinstance(shipments, list) or len(shipments) <= 1:
+ return shipments
+ if not isinstance(sources, list) or len(sources) != 1:
+ return shipments
+
+ # Доп. эвристика: если LLM вернул один shipment, но в письме указано несколько
+ # явных кодов позиций (SKU/модель/артикул), попробуем разделить на несколько shipments.
+ # Важно: делаем это ТОЛЬКО когда числовые поля (вес/кол-во/габариты) не готовы к корректному распределению.
+ if len(shipments) == 1:
+ only = shipments[0] if isinstance(shipments[0], dict) else None
+ if isinstance(only, dict):
+ split_by_codes = self._split_single_shipment_by_position_codes(only, sources[0])
+ if isinstance(split_by_codes, list) and len(split_by_codes) > 1:
+ logger.info(
+ "Split one shipment into %d by cargo position codes (single-email session)",
+ len(split_by_codes),
+ )
+ return split_by_codes
+
+ def _norm_info_str(v: Any) -> str:
+ if isinstance(v, list):
+ parts = []
+ for x in v:
+ s = str(x).strip() if x is not None else ""
+ if s:
+ parts.append(s)
+ v = ", ".join(parts)
+
+ if not isinstance(v, str):
+ return ""
+ s = v.strip()
+ if not s:
+ return ""
+ low = s.lower()
+ if low in {"не указано", "информация отсутствует", "нет", "unknown", "n/a", "na", "none"}:
+ return ""
+ return s
+
+ def _bool_or_none(v: Any) -> Optional[bool]:
+ if v is True:
+ return True
+ if v is False:
+ return False
+ return None
+
+ def _any_true_dg(dg: Any) -> bool:
+ if not isinstance(dg, dict):
+ return False
+ for k in ("batteries", "gases", "liquids", "dry_ice"):
+ if dg.get(k) is True:
+ return True
+ return False
+
+ def _conflict_score(a: Dict, b: Dict) -> int:
+ score = 0
+ # =========================================================
+ # Разные палеты / разные габариты = НЕ объединяем
+ # =========================================================
+
+ a_dims = a.get("dimensions") if isinstance(a.get("dimensions"), list) else []
+ b_dims = b.get("dimensions") if isinstance(b.get("dimensions"), list) else []
+
+ if a_dims and b_dims:
+
+ def _dims_signature(dims):
+ sig = []
+
+ for d in dims:
+ if not isinstance(d, dict):
+ continue
+
+ l = d.get("length_cm")
+ w = d.get("width_cm")
+ h = d.get("height_cm")
+
+ if l is None or w is None or h is None:
+ continue
+
+ try:
+ sig.append((
+ round(float(l), 1),
+ round(float(w), 1),
+ round(float(h), 1),
+ ))
+ except Exception:
+ continue
+
+ return sorted(sig)
+
+ if _dims_signature(a_dims) != _dims_signature(b_dims):
+ score += 100
+ a_brand = _norm_info_str(a.get("brand_name"))
+ b_brand = _norm_info_str(b.get("brand_name"))
+ if a_brand and b_brand and a_brand != b_brand:
+ score += 2
+
+ a_hs = _norm_info_str(a.get("hs_code"))
+ b_hs = _norm_info_str(b.get("hs_code"))
+ if a_hs and b_hs and a_hs != b_hs:
+ score += 2
+
+ a_cd = _norm_info_str(a.get("cargo_description"))
+ b_cd = _norm_info_str(b.get("cargo_description"))
+ if a_cd and b_cd and a_cd != b_cd:
+ score += 2
+
+ # Готовность к отгрузке может отличаться для разных партий
+ a_rd = _norm_info_str(a.get("cargo_ready_date"))
+ b_rd = _norm_info_str(b.get("cargo_ready_date"))
+ if a_rd and b_rd and a_rd != b_rd:
+ score += 1
+
+ # Ценность может отличаться для разных партий/позиции
+ a_val = _norm_info_str(a.get("cargo_value"))
+ b_val = _norm_info_str(b.get("cargo_value"))
+ if a_val and b_val and a_val != b_val:
+ score += 1
+ if _strong_invoice_value_mismatch(
+ str(a.get("cargo_value") or ""), str(b.get("cargo_value") or "")
+ ):
+ score += 4
+
+ for key in ("msds_required", "dgm_report_required", "brand_authorization_letter"):
+ av = _bool_or_none(a.get(key))
+ bv = _bool_or_none(b.get(key))
+ if av is not None and bv is not None and av != bv:
+ score += 2
+
+ a_dg = _any_true_dg(a.get("dangerous_goods"))
+ b_dg = _any_true_dg(b.get("dangerous_goods"))
+ if a_dg != b_dg:
+ score += 1
+
+ return score
+
+ clusters: List[Dict] = []
+ for s in shipments:
+ if not isinstance(s, dict):
+ continue
+ placed = False
+ for base in clusters:
+ if _conflict_score(base, s) <= 1:
+ self._merge_shipment_into(base, s)
+ placed = True
+ break
+ if not placed:
+ clusters.append(dict(s))
+
+ logger.info(
+ "Clustered %d shipments (single email in session) -> %d",
+ len(shipments),
+ len(clusters),
+ )
+ return clusters
+
+ def _split_single_shipment_by_position_codes(
+ self, shipment: Dict, source: Dict
+ ) -> List[Dict]:
+ """
+ Пытается разделить один shipment на несколько, если в тексте видны несколько
+ разных кодов позиций (например, trocyps-26003, trocyps-26004).
+
+ Делает это только когда нет явных числовых распределений, чтобы не было риска
+ удвоить веса/габариты.
+ """
+ if not isinstance(shipment, dict) or not isinstance(source, dict):
+ return [shipment]
+
+ head_txt = " ".join(
+ str(x or "")
+ for x in (
+ source.get("subject"),
+ source.get("content"),
+ )
+ )
+ if _is_tender_multi_origin_context(head_txt):
+ logger.info(
+ "Skip split by position codes: tender / multi-origin pickup context detected"
+ )
+ return [shipment]
+
+ # Числовые поля: если они заполнены, мы не знаем как корректно распределить по позициям.
+ # Тогда безопаснее не разделять детерминированно.
+ if shipment.get("total_weight_kg") not in [None, "", "не указан"]:
+ return [shipment]
+ if shipment.get("package_count") not in [None, "", "не указан"]:
+ return [shipment]
+ dims = shipment.get("dimensions")
+ if isinstance(dims, list) and dims:
+ return [shipment]
+ # Объём/CBM тоже лучше не делить без правил.
+ if shipment.get("total_volume_cbm") not in [None, "", "не указан"]:
+ return [shipment]
+
+ # Берём максимум текста, чтобы коды точно нашлись
+ blob = self._collect_shipment_source_text(shipment, [source]) or ""
+
+ # Основной кейс: trocyps-12345 / trocyps_12345 / trocyps 12345
+ codes = re.findall(r"\btrocyps[-_\s]?\d+\b", blob, flags=re.IGNORECASE)
+ if not codes:
+ # Общая эвристика: ABC-12345 (3+ буквы/цифры через дефис)
+ codes = re.findall(r"\b[A-Za-z]{2,}[-_]\d{2,}\b", blob)
+
+ # Нормализуем в нижний регистр и дедуплицируем
+ norm_codes: List[str] = []
+ seen = set()
+ for c in codes:
+ cc = str(c).strip()
+ if not cc:
+ continue
+ key = cc.lower()
+ if key in seen:
+ continue
+ seen.add(key)
+ norm_codes.append(cc)
+
+ if len(norm_codes) < 2:
+ return [shipment]
+
+ # Разрезаем cargo_description (или blob) по появлениям кодов и берём сегменты.
+ cargo_src = shipment.get("cargo_description")
+ cargo_src_str = cargo_src if isinstance(cargo_src, str) else ""
+ split_text = cargo_src_str.strip() or blob
+
+ # Определяем индексы в split_text
+ indices: List[tuple[int, str]] = []
+ lower_split = split_text.lower()
+ for code in norm_codes:
+ idx = lower_split.find(code.lower())
+ if idx >= 0:
+ indices.append((idx, code))
+
+ if len(indices) < 2:
+ return [shipment]
+
+ indices.sort(key=lambda x: x[0])
+ segments: List[str] = []
+ for i, (start, code) in enumerate(indices):
+ end = indices[i + 1][0] if i + 1 < len(indices) else len(split_text)
+ seg = split_text[start:end].strip()
+ segments.append(seg if seg else code)
+
+ out: List[Dict] = []
+ for i, seg in enumerate(segments):
+ new_s = dict(shipment)
+ # Положим в описание сегмент вокруг конкретного кода позиции
+ # Верхний предел только против аномально больших строк (см. RAG_MAX_SEGMENT_CHARS)
+ _max_seg = _max_cargo_segment_chars()
+ new_s["cargo_description"] = seg if _max_seg <= 0 else seg[:_max_seg]
+ out.append(new_s)
+
+ return out
+
+ def _merge_shipments_same_destination(
+ self, shipments: List[Dict], context_text: str = ""
+ ) -> List[Dict]:
+ """
+ Объединяет перевозки с одинаковым адресом выгрузки в одну запись.
+ Поля объединяются консервативно. Если по «смысловым якорям» (бренд, HS, тип, нужные документы)
+ видны различающиеся партии/заказа, shipments разделяются на несколько записей.
+ Для тендеров с несколькими адресами забора и одной выгрузкой — мягче порог склейки.
+ """
+ if not isinstance(shipments, list) or len(shipments) <= 1:
+ return shipments
+
+ tender_relaxed = _is_tender_multi_origin_context(context_text or "")
+ merge_threshold = 4 if tender_relaxed else 1
+
+ # 1) Сначала группируем по месту выгрузки.
+ buckets: Dict[str, List[Dict]] = {}
+ passthrough: List[Dict] = []
+ for s in shipments:
+ if not isinstance(s, dict):
+ continue
+ dest = (s.get("delivery_address") or "").strip()
+ dest_key = self._normalize_text_key(dest)
+ if not dest_key:
+ passthrough.append(s)
+ continue
+ buckets.setdefault(dest_key, []).append(s)
+
+ # 2) Объединяем все перевозки в группе с одним и тем же нормализованным адресом выгрузки
+ # (две и больше записей). Сначала строим кластеры внутри группы по delivery_address.
+ # Правило: если есть явные конфликты по бренду/HS/типу/документам — не сливаем.
+
+ def _norm_info_str(v: Any) -> str:
+ if not isinstance(v, str):
+ return ""
+ s = v.strip()
+ if not s:
+ return ""
+ low = s.lower()
+ # Пустые/служебные значения, которые нельзя считать «доказательством».
+ if low in {"не указано", "информация отсутствует", "нет", "unknown", "n/a", "na", "none"}:
+ return ""
+ return s
+
+ def _bool_or_none(v: Any) -> Optional[bool]:
+ if v is True:
+ return True
+ if v is False:
+ return False
+ return None
+
+ def _any_true_dg(dg: Any) -> bool:
+ if not isinstance(dg, dict):
+ return False
+ for k in ("batteries", "gases", "liquids", "dry_ice"):
+ if dg.get(k) is True:
+ return True
+ return False
+
+ def _conflict_score(a: Dict, b: Dict) -> int:
+ score = 0
+
+ a_brand = _norm_info_str(a.get("brand_name"))
+ b_brand = _norm_info_str(b.get("brand_name"))
+ if a_brand and b_brand and a_brand != b_brand:
+ score += 2
+
+ a_hs = _norm_info_str(a.get("hs_code"))
+ b_hs = _norm_info_str(b.get("hs_code"))
+ if a_hs and b_hs and a_hs != b_hs:
+ score += 2
+
+ # Тип перевозки (если он распознан у обоих)
+ a_st = _norm_info_str(a.get("shipping_type"))
+ b_st = _norm_info_str(b.get("shipping_type"))
+ if a_st and b_st and a_st != b_st:
+ score += 1
+
+ # Описание груза (часто соответствует разным позициям/партиям)
+ a_cd = _norm_info_str(a.get("cargo_description"))
+ b_cd = _norm_info_str(b.get("cargo_description"))
+ if a_cd and b_cd and a_cd != b_cd:
+ score += 2
+ if tender_relaxed and (
+ ("стм" in a_cd.lower() and "стм" in b_cd.lower())
+ or (a_cd.lower() in b_cd.lower() or b_cd.lower() in a_cd.lower())
+ ):
+ score -= 1
+
+ # Готовность к отгрузке может отличаться для разных партий
+ a_rd = _norm_info_str(a.get("cargo_ready_date"))
+ b_rd = _norm_info_str(b.get("cargo_ready_date"))
+ if a_rd and b_rd and a_rd != b_rd:
+ if not tender_relaxed:
+ score += 1
+
+ # Ценность (если отличаемая в письме)
+ a_val = _norm_info_str(a.get("cargo_value"))
+ b_val = _norm_info_str(b.get("cargo_value"))
+ if a_val and b_val and a_val != b_val:
+ score += 1
+ if _strong_invoice_value_mismatch(
+ str(a.get("cargo_value") or ""), str(b.get("cargo_value") or "")
+ ):
+ score += 4
+
+ # Документы/требования: конфликт если одно явно True, другое явно False
+ for key in ("msds_required", "dgm_report_required", "brand_authorization_letter"):
+ av = _bool_or_none(a.get(key))
+ bv = _bool_or_none(b.get(key))
+ if av is not None and bv is not None and av != bv:
+ score += 2
+
+ # Опасный груз: конфликт если у одного явно опасность категорий, у другого нет
+ a_dg = _any_true_dg(a.get("dangerous_goods"))
+ b_dg = _any_true_dg(b.get("dangerous_goods"))
+ if a_dg != b_dg:
+ score += 1
+
+ return score
+
+ merged: List[Dict] = []
+ for dest_key, group in buckets.items():
+ if len(group) < 2:
+ passthrough.extend(group)
+ continue
+
+ clusters: List[Dict] = []
+ for s in group:
+ if not isinstance(s, dict):
+ continue
+ placed = False
+ for base in clusters:
+ # Порог: обычно строгий; для тендера с N точками забора — выше (разные даты — норма).
+ if _conflict_score(base, s) <= merge_threshold:
+ self._merge_shipment_into(base, s)
+ placed = True
+ break
+ if not placed:
+ clusters.append(dict(s))
+
+ merged.extend(clusters)
+
+ merged = merged + passthrough
+ logger.info(
+ "Merged shipments by destination (clustered): %s -> %s (tender_relaxed=%s, thr=%s)",
+ len(shipments),
+ len(merged),
+ tender_relaxed,
+ merge_threshold,
+ )
+ return merged
+
+ def _extract_container_mentions(self, text: str) -> List[str]:
+ if not isinstance(text, str) or not text.strip():
+ return []
+
+ t = text.lower().replace("'", "").replace('"', "")
+ t = t.replace("ф", "f").replace("х", "x")
+
+ results: List[str] = []
+
+ def _normalize_type(size: str, kind: str) -> str:
+ k = (kind or "").lower().strip()
+ kind_map = {
+ "hc": "HC", "hq": "HC",
+ "dc": "DC", "gp": "DC",
+ "rf": "RF", "rh": "RF", "reefer": "RF", "ref": "RF", "реф": "RF", "рефр": "RF",
+ "ot": "OT",
+ "fr": "FR",
+ }
+ k_norm = kind_map.get(k, "")
+ return f"{size}{k_norm}" if k_norm else size
+
+ # Варианты с количеством: 2*40HC, 3x20DC, 2 40hc
+ p_count = re.compile(
+ r"(? Optional[str]:
+ if not isinstance(shipment, dict):
+ return None
+ email_ids = set(shipment.get("ID_emails") or [])
+ if not email_ids:
+ return None
+
+ text_chunks: List[str] = []
+ for src in sources or []:
+ if src.get("id") not in email_ids:
+ continue
+ for key in ["subject", "content"]:
+ val = src.get(key)
+ if isinstance(val, str) and val.strip():
+ text_chunks.append(val)
+ for att in src.get("attachments", []) or []:
+ att_text = att.get("text")
+ if isinstance(att_text, str) and att_text.strip():
+ text_chunks.append(att_text)
+
+ if not text_chunks:
+ return None
+
+ mentions = self._extract_container_mentions("\n".join(text_chunks))
+ return ", ".join(mentions) if mentions else None
+
+ def _collect_shipment_source_text(
+ self,
+ shipment: Dict,
+ sources: List[Dict],
+ *,
+ include_email: bool = True,
+ include_attachments: bool = True,
+ ) -> str:
+ """Текст по перевозке (по ID_emails) с управлением источниками."""
+ if not isinstance(shipment, dict):
+ return ""
+ email_ids = set(shipment.get("ID_emails") or [])
+ parts: List[str] = []
+ for src in sources or []:
+ if src.get("id") not in email_ids:
+ continue
+ if include_email:
+ for key in ("subject", "content"):
+ val = src.get(key)
+ if isinstance(val, str) and val.strip():
+ parts.append(val)
+ if include_attachments:
+ for att in src.get("attachments", []) or []:
+ att_text = att.get("text")
+ if isinstance(att_text, str) and att_text.strip():
+ parts.append(att_text)
+ return "\n".join(parts)
+
+ @staticmethod
+ def _text_mentions_china_context(text: str) -> bool:
+ if not isinstance(text, str) or not text.strip():
+ return False
+ t = text.lower()
+ markers = (
+ "китай", "china", "кнр", "chinese", "mainland china", "материковый китай",
+ "shenzhen", "shanghai", "guangzhou", "ningbo", "yiwu", "qingdao", "xiamen",
+ "foshan", "tianjin", "beijing", "hong kong", "гонконг", "guangdong",
+ "zhejiang", "jiangsu", "fujian", "义乌", "深圳", "广州", "宁波",
+ )
+ return any(m in t for m in markers)
+
+ def _infer_brand_and_authorization_from_sources(self, shipment: Dict, sources: List[Dict]) -> None:
+ """Достаёт название бренда из текста писем и выставляет необходимость авторизационного письма."""
+ if not isinstance(shipment, dict):
+ return
+ email_blob = self._collect_shipment_source_text(
+ shipment, sources, include_email=True, include_attachments=False
+ )
+ att_blob = self._collect_shipment_source_text(
+ shipment, sources, include_email=False, include_attachments=True
+ )
+ blob = email_blob if email_blob.strip() else att_blob
+ if not isinstance(blob, str) or not blob.strip():
+ return
+ low = blob.lower()
+
+ bn = shipment.get("brand_name")
+ if not (isinstance(bn, str) and bn.strip()):
+ brand_patterns = [
+ r"(?:бренд|brand|trademark|\bтм\b)\s*[::]\s*([^\n\r,;]{1,2000})",
+ r"торгов(?:ая|ой)\s+марка\s*[::]\s*([^\n\r,;]{1,2000})",
+ r"марк[аи]\s+товара\s*[::]\s*([^\n\r,;]{1,2000})",
+ r"(?:производител[ья]|vendor|manufacturer|oem)\s*[::]\s*([^\n\r,;]{1,2000})",
+ r"logo\s*[::]\s*([^\n\r,;]{1,2000})",
+ ]
+ for pat in brand_patterns:
+ m = re.search(pat, blob, flags=re.IGNORECASE)
+ if not m:
+ continue
+ raw_name = m.group(1).strip()
+ raw_name = raw_name.strip('«»"\'•· \t').strip()
+ raw_name = re.split(r"\s{2,}|\t", raw_name)[0].strip()
+ if not raw_name or len(raw_name) > 4000:
+ continue
+ bad = {"n/a", "na", "none", "нет", "no", "tbd", "—", "-", "same as above"}
+ if raw_name.lower() in bad:
+ continue
+ shipment["brand_name"] = raw_name[:4000]
+ break
+
+ auth_patterns = (
+ r"авторизационн\w*\s+письм",
+ r"authorization\s+letter",
+ r"authorisation\s+letter",
+ r"brand\s+authori[sz]ation",
+ r"loa\b|\bletter\s+of\s+authorization\b",
+ r"письм\w*\s+(?:от\s+)?бренд",
+ r"разрешени\w*\s+(?:от\s+)?бренд",
+ r"бренд\w*\s+в\s+таможенн",
+ r"таможенн\w*\s+систем\w*\s+китая",
+ r"китайск\w*\s+таможен",
+ r"регистрац\w*\s+бренд",
+ r"trademark\s+registration",
+ r"\b1688\b|1688\.com|tmall|taobao",
+ )
+ if any(re.search(p, low) for p in auth_patterns):
+ shipment["brand_authorization_letter"] = True
+
+ loc_blob = " ".join(
+ str(x)
+ for x in (
+ shipment.get("pickup_address"),
+ shipment.get("loading_port"),
+ shipment.get("delivery_address"),
+ shipment.get("cargo_description"),
+ blob,
+ )
+ if x
+ )
+ if self._text_mentions_china_context(loc_blob):
+ b = shipment.get("brand_name")
+ if isinstance(b, str) and b.strip():
+ shipment["brand_authorization_letter"] = True
+
+ # Текстовая сводка по пункту авторизационного письма (для вывода в отчёте/шаблонах).
+ lines_email = [ln.strip() for ln in email_blob.splitlines() if isinstance(ln, str) and ln.strip()]
+ lines_att = [ln.strip() for ln in att_blob.splitlines() if isinstance(ln, str) and ln.strip()]
+ auth_lines: List[str] = []
+ auth_need_re = re.compile(
+ r"авторизационн\w*\s+письм|authorization\s+letter|authorisation\s+letter|"
+ r"letter\s+of\s+authorization|\bloa\b|регистрац\w*\s+бренд|trademark\s+registration|"
+ r"бренд\w*\s+в\s+таможенн",
+ re.IGNORECASE,
+ )
+ auth_not_needed_re = re.compile(
+ r"авторизационн\w*\s+письм\w*\s+не\s+(?:нужн|треб)|"
+ r"authorization\s+letter\s+(?:is\s+)?not\s+required|no\s+authorization\s+letter",
+ re.IGNORECASE,
+ )
+ for ln in lines_email:
+ if auth_need_re.search(ln):
+ auth_lines.append(re.sub(r"\s+", " ", ln))
+ # Если есть явное "не требуется", это тоже важно показать.
+ for ln in lines_email:
+ if auth_not_needed_re.search(ln):
+ clean = re.sub(r"\s+", " ", ln)
+ if clean not in auth_lines:
+ auth_lines.append(clean)
+ # Только если в письме ничего не найдено — добираем релевантные строки из вложений.
+ if not auth_lines:
+ for ln in lines_att:
+ if auth_need_re.search(ln) or auth_not_needed_re.search(ln):
+ auth_lines.append("[вложение] " + re.sub(r"\s+", " ", ln))
+
+ docs = shipment.get("documents_found", {}) or {}
+ files = docs.get("brand_authorization") or []
+ if isinstance(files, list) and files:
+ fnames = [str(x.get("filename") or "").strip() for x in files if isinstance(x, dict)]
+ fnames = [f for f in fnames if f]
+ if fnames:
+ auth_lines.append("Найдены вложения: " + ", ".join(dict.fromkeys(fnames)))
+
+ if auth_lines:
+
+ shipment["brand_authorization_info"] = (
+ " | ".join(
+ dict.fromkeys(auth_lines)
+ )[:3000]
+ )
+
+ else:
+
+ flag = shipment.get(
+ "brand_authorization_letter"
+ )
+
+ if flag is True:
+
+ shipment["brand_authorization_info"] = (
+ "Требуется авторизационное письмо бренда, "
+ "подробности в письме не найдены."
+ )
+
+ elif flag is False:
+
+ shipment["brand_authorization_info"] = (
+ "Авторизационное письмо бренда не требуется."
+ )
+
+ elif flag == "":
+
+ shipment["brand_authorization_info"] = (
+ "Информация отсутствует"
+ )
+
+ def _infer_document_replacement_from_sources(self, shipment: Dict, sources: List[Dict]) -> None:
+ """Замена документов: true/false только при явной формулировке в переписке; иначе null."""
+ if not isinstance(shipment, dict):
+ return
+ if shipment.get("document_replacement_needed") is not None:
+ return
+ blob = self._collect_shipment_source_text(shipment, sources)
+ if not isinstance(blob, str) or not blob.strip():
+ return
+ low = blob.lower()
+ if not re.search(
+ r"замен\w*\s+документ|переоформл\w*\s+документ|документ\w*\s+к\s+замене|"
+ r"document\s+replacement|replace\s+(?:the\s+)?documents|substitut\w*\s+documents",
+ low,
+ re.IGNORECASE,
+ ):
+ return
+ if re.search(
+ r"замен\w*\s+не\s+нуж|замен\w*\s+не\s+треб|не\s+нужна\s+замен|"
+ r"no\s+document\s+replacement|replacement\s+not\s+required",
+ low,
+ re.IGNORECASE,
+ ):
+ shipment["document_replacement_needed"] = False
+ return
+ shipment["document_replacement_needed"] = True
+
+ @staticmethod
+ def _text_describes_parallel_sea_rail_alternatives(text: str) -> bool:
+ """
+ Ж/д и море упомянуты как разные варианты маршрута (один ИЛИ другой), а не как одна
+ интермодальная цепочка «море + ж/д» в одной поставке.
+ Пример: «Рассматривают 2 варианта: прямые рейсы ЖД / прямые суда через ДВ…».
+ """
+ if not isinstance(text, str) or not text.strip():
+ return False
+ t = text.lower().replace("ё", "е")
+ # Явно одна мультимодальная цепочка — не отключаем.
+ if re.search(
+ r"море\s*\+\s*жд|море\s+и\s+ж/д|интермодал|intermodal|"
+ r"sea\s*\+\s*rail|sea\s+and\s+rail|"
+ r"морск\w*\s+этап.{0,120}ж\/?д\s+этап|ж\/?д\s+этап.{0,120}морск",
+ t,
+ re.IGNORECASE | re.DOTALL,
+ ):
+ return False
+ if re.search(
+ r"рассматрива\w*\s+(?:\d+\s+)?варианта|(?:^|[\n•·●◦\-\–—])\s*(?:два|2)\s+варианта",
+ t,
+ re.IGNORECASE,
+ ):
+ return True
+ # «выход поезда/суда» у морской схемы через ДВ — срок одного из плеч, не продукт «море+жд».
+ if re.search(r"поезд[аы]?\s*/\s*судна", t) and re.search(
+ r"через\s+дв|дальн\w*\s*восток|\bдв\b",
+ t,
+ re.IGNORECASE,
+ ):
+ return True
+ # Две строки-альтернативы: прямой ж/д и отдельно морские суда.
+ if re.search(
+ r"прям\w+\s+рейс\w*.{0,100}(?:ж/д|\bжд\b)",
+ t,
+ re.IGNORECASE,
+ ) and re.search(r"прям\w+\s+суд", t, re.IGNORECASE):
+ return True
+ return False
+
+ @staticmethod
+ def _text_implies_sea_and_rail_multimodal(text: str) -> bool:
+ """В одной заявке явно и морской, и ж/д этап (не «только море»)."""
+ if not isinstance(text, str) or not text.strip():
+ return False
+ t = text.lower()
+ if re.search(
+ r"море\s*\+\s*жд|море\s+и\s+ж/д|море\s*[-–]\s*жд|ж/д\s*\+\s*море|море\+жд|"
+ r"sea\s*\+\s*rail|sea\s+and\s+rail|sea\s*[-–]\s*rail|"
+ r"мультимодал|intermodal|морск\w*\s+и\s+железнодор|порт\w*\s+.*\s+станц",
+ t,
+ re.IGNORECASE,
+ ):
+ return True
+ if re.search(
+ r"только\s+море|только\s+морск|sea\s+only|без\s+ж/д|без\s+жд|no\s+rail",
+ t,
+ re.IGNORECASE,
+ ):
+ return False
+ if RAGEngineGemini._text_describes_parallel_sea_rail_alternatives(text):
+ return False
+ sea = bool(
+ re.search(
+ r"\bморск|\bморе\b|sea\s+freight|ocean\s+freight|морской\s+порт|"
+ r"порт\s+погруз|vessel|судно|суда|судов|морским\s+пут",
+ t,
+ re.IGNORECASE,
+ )
+ )
+ rail = bool(
+ re.search(
+ r"ж/д|железнодор|railway|\brail\b|\bжд\b|"
+ r"станци|платформ|1520|вагон|container\s+train|по\s+жд|на\s+жд|"
+ r"rail\s+transport|жд\s+этап|\sжд\s|[,;]\s*жд\b",
+ t,
+ re.IGNORECASE,
+ )
+ )
+ return bool(sea and rail)
+
+ def _correct_multimodal_sea_rail_for_shipment(self, shipment: Dict, combined_text: str) -> None:
+ """Если выбрана чистая морская перевозка, а в тексте явно море+ж/д — тип «Мультимодальная …»."""
+ if not isinstance(shipment, dict):
+ return
+ if not self._text_implies_sea_and_rail_multimodal(combined_text):
+ return
+ sea_only = frozenset({"Морская перевозка (FCL)", "Морская перевозка (LCL)"})
+
+ def _pure_sea(nm: str) -> bool:
+ return (nm or "").strip() in sea_only
+
+ st_t = (shipment.get("shipment_type") or "").strip().upper()
+
+ def _target_multimodal(from_name: str) -> Optional[str]:
+ if not _pure_sea(from_name):
+ return None
+ fcl = "FCL" in from_name
+ if st_t == "LCL":
+ fcl = False
+ elif st_t == "FCL":
+ fcl = True
+ cand = (
+ "Мультимодальная перевозка море + ж/д (FCL)"
+ if fcl
+ else "Мультимодальная перевозка море + ж/д (LCL)"
+ )
+ return cand if self._shipping_type_record_by_name(cand) else None
+
+ raw_req = shipment.get("requested_shipping_type_names")
+ if isinstance(raw_req, list) and raw_req:
+ new_req: List[str] = []
+ changed = False
+ for x in raw_req:
+ if not isinstance(x, str):
+ new_req.append(x)
+ continue
+ n = x.strip()
+ t = _target_multimodal(n)
+ if t:
+ new_req.append(t)
+ changed = True
+ else:
+ new_req.append(n)
+ if changed:
+ shipment["requested_shipping_type_names"] = new_req
+ return
+
+ st = (shipment.get("shipping_type") or "").strip()
+ t = _target_multimodal(st)
+ if t:
+ shipment["shipping_type"] = t
+
+ def _infer_dangerous_goods_from_sources(self, shipment: Dict, sources: List[Dict]) -> None:
+ """Дополняет dangerous_goods по тексту писем/вложений (категории батареи/газы/жидкости/сухой лёд)."""
+ if not isinstance(shipment, dict):
+ return
+ email_blob = self._collect_shipment_source_text(
+ shipment, sources, include_email=True, include_attachments=False
+ )
+ att_blob = self._collect_shipment_source_text(
+ shipment, sources, include_email=False, include_attachments=True
+ )
+ blob = email_blob if email_blob.strip() else att_blob
+ if not isinstance(blob, str) or not blob.strip():
+ return
+ low_email = email_blob.lower() if isinstance(email_blob, str) else ""
+ low_att = att_blob.lower() if isinstance(att_blob, str) else ""
+ low_subject = ""
+
+ for s in sources:
+
+ subj = (
+ s.get("subject")
+ or s.get("metadata", {}).get("subject")
+ or ""
+ )
+
+ if subj:
+ low_subject += "\n" + subj.lower()
+ dg = shipment.get("dangerous_goods")
+ if not isinstance(dg, dict):
+ dg = {}
+ for key in ("batteries", "gases", "liquids", "dry_ice"):
+ if key not in dg:
+ dg[key] = None
+ shipment["dangerous_goods"] = dg
+
+ def _hit(pattern: str) -> bool:
+ # Приоритет письма: сначала ищем в тексте письма, потом во вложениях.
+ if low_email and re.search(pattern, low_email, re.IGNORECASE):
+ return True
+ if low_att and re.search(pattern, low_att, re.IGNORECASE):
+ return True
+ return False
+
+ if dg.get("batteries") is None:
+ if _hit(
+ r"литий|lithium|li\s*[- ]?ion|liion|батаре|аккумулятор|battery|batteries|"
+ r"\bcells?\b|\bbutton\s+cell\b|\bpower\s*bank\b|"
+ r"\bun\s*348[01]\b|\bun\s*3090\b|\bun\s*3091\b|\bun\s*3171\b",
+ ):
+ dg["batteries"] = True
+
+ if dg.get("gases") is None:
+ if _hit(
+ r"газовый\s+баллон|сжиженн\w*\s+газ|gas\s+cylinder|аэрозол|aerosol|"
+ r"compressed\s+gas|баллон\s+с\s+газ|\blpg\b|\blng\b|"
+ r"\bco2\b|углекисл\w+\s+газ|пропан|бутан|class\s*2\b|класс\s*2\b|"
+ r"\bun\s*1950\b|\bun\s*1013\b",
+ ):
+ dg["gases"] = True
+
+ if dg.get("liquids") is None:
+ if _hit(
+ r"легковоспламен|воспламен|flammable|combustible\s+liquid|жидкост.*опасн|"
+ r"class\s*3\b|класс\s*3\s*опасност|имдг|imdg|"
+ r"\bun\s*12\d{2}\b|\bun\s*19\d{2}\b|\bун\s*\d{4}\b|\bun\s*\d{4}\b|"
+ r"solvent|растворител|paint|краск|resin|смола|ink|чернил|ethanol|спирт|acetone|ацетон",
+ ):
+ dg["liquids"] = True
+
+ if dg.get("dry_ice") is None:
+ if _hit(r"сухой\s+л[её]д|dry\s+ice|\bun\s*1845\b"):
+ dg["dry_ice"] = True
+
+ def _detect_battery_mode(text: str) -> Optional[str]:
+
+ if not isinstance(text, str):
+ return None
+
+ t = text.lower()
+
+ # батареи внутри оборудования
+ contained_patterns = [
+ r"packed with equipment",
+ r"contained in equipment",
+ r"installed in",
+ r"built[-\s]?in battery",
+ r"battery inside",
+ r"в составе",
+ r"встроенн\w+\s+батар",
+ r"внутри оборудования",
+ ]
+
+ # батареи отдельно
+ standalone_patterns = [
+ r"standalone battery",
+ r"battery only",
+ r"batteries only",
+ r"without equipment",
+ r"loose battery",
+ r"отдельн\w+\s+батар",
+ r"батарейки отдельно",
+ ]
+
+ for p in standalone_patterns:
+ if re.search(p, t, re.IGNORECASE):
+ return "Отдельно"
+
+ for p in contained_patterns:
+ if re.search(p, t, re.IGNORECASE):
+ return "В составе"
+
+ return None
+
+ # Явное "не опасный" / non-DG — проставляем False по пустым категориям.
+ def _negation_scan(pattern_text: str, keywords: dict) -> None:
+ """Проверяет текст на наличие 'не содержит...' и выставляет False для совпавших категорий."""
+ neg_match = re.search(r'не\s+содержит\s+(?P[^\.;]+)', pattern_text, re.IGNORECASE)
+ if not neg_match:
+ return
+ items = neg_match.group('items').lower()
+ for cat, word_list in keywords.items():
+ if any(w in items for w in word_list):
+ if dg.get(cat) is None:
+ dg[cat] = False
+
+ dangerous_keywords = {
+ "batteries": ["батаре", "аккумулятор", "элемент питания", "batter"],
+ "gases": ["газ", "аэрозол", "баллон", "gas", "aerosol"],
+ "liquids": ["жидкост", "liquid", "растворител", "краск", "смол", "масл", "чернил"],
+ "dry_ice": ["сухого льда", "сухой лёд", "dry ice"]
+ }
+
+ _negation_scan(low_email, dangerous_keywords)
+ _negation_scan(low_att, dangerous_keywords)
+
+ battery_mode = _detect_battery_mode(
+ f"{low_subject}\n{low_email}\n{low_att}"
+ )
+
+ if battery_mode:
+ shipment["battery_mode"] = battery_mode
+
+ explicit_non_dg = bool(
+ (low_email and re.search(
+ r"\bnon[\s\-]?dg\b|not\s+dangerous|not\s+hazardous|"
+ r"not\s+classified\s+as\s+dangerous|"
+ r"без\s+опасн\w+\s+груз|"
+ r"не\s+опасн\w+\s+груз|"
+ r"груз\s+не\s*опасн\w*|"
+ r"груз\s+не\s+явля\w+\s+опасн\w*|"
+ r"не\s+явля\w+\s+опасн\w*|"
+ r"неопасн\w+\s+груз|"
+ r"опасн\w+\s+груз\w*\s+нет|"
+ r"не\s+hazmat",
+ low_email,
+ re.IGNORECASE,
+ )) or
+ (low_att and re.search(
+ r"\bnon[\s\-]?dg\b|not\s+dangerous|not\s+hazardous|"
+ r"not\s+classified\s+as\s+dangerous|"
+ r"без\s+опасн\w+\s+груз|"
+ r"не\s+опасн\w+\s+груз|"
+ r"груз\s+не\s*опасн\w*|"
+ r"груз\s+не\s+явля\w+\s+опасн\w*|"
+ r"не\s+явля\w+\s+опасн\w*|"
+ r"неопасн\w+\s+груз|"
+ r"опасн\w+\s+груз\w*\s+нет|"
+ r"не\s+hazmat",
+ low_att,
+ re.IGNORECASE,
+ ))
+ )
+ if explicit_non_dg:
+ for k in ("batteries", "gases", "liquids", "dry_ice"):
+ dg[k] = False
+ shipment.pop("dangerous_goods_note", None)
+ # Явное "не опасный груз" приоритетнее: MSDS и DGM отмечаем как не нужные.
+ shipment["msds_required"] = False
+ shipment["dgm_report_required"] = False
+
+ any_true = any(dg.get(k) is True for k in ("batteries", "gases", "liquids", "dry_ice"))
+ if any_true:
+ # Не затираем уже существующий note, но и не навязываем.
+ return
+ # Обобщенный индикатор опасности, если категория не распознана.
+ generic_dg = bool(
+ (low_email and re.search(
+ r"\bdg\b|dangerous\s+goods|hazardous\s+(?:goods|material)|hazmat|imdg|"
+ r"опасн\w+\s+груз|класс\s+опасност|class\s+[1-9]\b|"
+ r"\bun\s*\d{4}\b|ун\s*\d{4}",
+ low_email,
+ re.IGNORECASE,
+ )) or
+ (low_att and re.search(
+ r"\bdg\b|dangerous\s+goods|hazardous\s+(?:goods|material)|hazmat|imdg|"
+ r"опасн\w+\s+груз|класс\s+опасност|class\s+[1-9]\b|"
+ r"\bun\s*\d{4}\b|ун\s*\d{4}",
+ low_att,
+ re.IGNORECASE,
+ ))
+ )
+ if generic_dg:
+ # По требованию UI: в 9-м пункте показываем только факт Да/Нет/Нет информации.
+ # Если есть лишь общий намёк на DG без конкретной категории, не подставляем текстовый note.
+ shipment.pop("dangerous_goods_note", None)
+
+ def _extract_quantities_from_text(self, raw_text: str) -> Dict[str, Any]:
+ """
+ Детерминированное извлечение тройки «ctn/ctns + kg + cbm».
+ Это нужно, чтобы не брать первое попавшееся значение (иногда в письме несколько частей).
+ """
+ if not isinstance(raw_text, str) or not raw_text.strip():
+ return {}
+
+ def _to_float(v: str) -> Optional[float]:
+ if not isinstance(v, str):
+ return None
+ s = v.strip().replace(" ", "")
+ s = s.replace(",", ".")
+ try:
+ return float(s)
+ except Exception:
+ return None
+
+ # Триплет в одном месте строки: "... 57ctns ... 673kgs ... 5.07cbm"
+ triplet_re = re.compile(
+ r"(?P\d+(?:[.,]\d+)?)\s*(?:ctns?|carton(?:s)?|boxes?)\b"
+ r".{0,80}?"
+ r"(?P\d+(?:[.,]\d+)?)\s*(?:kgs?|kg|кг)\b"
+ r".{0,80}?"
+ r"(?P\d+(?:[.,]\d+)?)\s*(?:cbm|m3|m³|м3|м³)\b",
+ re.IGNORECASE | re.DOTALL,
+ )
+
+ triplets: List[tuple[float, float, float]] = []
+ for m in triplet_re.finditer(raw_text):
+ ctn = _to_float(m.group("ctn"))
+ kg = _to_float(m.group("kg"))
+ cbm = _to_float(m.group("cbm"))
+ if ctn is None or kg is None or cbm is None:
+ continue
+ triplets.append((ctn, kg, cbm))
+
+ # Если триплетов мало/нет — пробуем раздельные совпадения.
+ ctn_re = re.compile(
+ r"(?\d+(?:[.,]\d+)?)\s*(?:ctns?|carton(?:s)?|boxes?)\b",
+ re.IGNORECASE,
+ )
+ kg_re = re.compile(
+ r"(?\d+(?:[.,]\d+)?)\s*(?:kgs?|kg|кг)\b",
+ re.IGNORECASE,
+ )
+ cbm_re = re.compile(
+ r"(?\d+(?:[.,]\d+)?)\s*(?:cbm|m3|m³|м3|м³)\b",
+ re.IGNORECASE,
+ )
+
+ ctn_matches = [_to_float(m.group("ctn")) for m in ctn_re.finditer(raw_text)]
+ kg_matches = [_to_float(m.group("kg")) for m in kg_re.finditer(raw_text)]
+ cbm_matches = [_to_float(m.group("cbm")) for m in cbm_re.finditer(raw_text)]
+
+ ctn_matches = [x for x in ctn_matches if x is not None]
+ kg_matches = [x for x in kg_matches if x is not None]
+ cbm_matches = [x for x in cbm_matches if x is not None]
+
+ # Дедупликация по числовому значению (защита от дублей в письме + вложении)
+ def _deduplicate_by_value(vals: List[float]) -> List[float]:
+ seen: set[int] = set()
+ result: List[float] = []
+ for v in vals:
+ # Округляем до целых (достаточно для кг/шт/м³)
+ key = round(v)
+ if key not in seen:
+ seen.add(key)
+ result.append(v)
+ return result
+
+ ctn_matches = _deduplicate_by_value(ctn_matches)
+ kg_matches = _deduplicate_by_value(kg_matches)
+ cbm_matches = _deduplicate_by_value(cbm_matches)
+
+ def _sum_or_none(vals: List[float]) -> Optional[float]:
+ if not vals:
+ return None
+ return float(sum(vals))
+
+ triplet_sum = None
+ if triplets:
+ triplet_sum = {
+ "package_count": _sum_or_none([t[0] for t in triplets]),
+ "total_weight_kg": _sum_or_none([t[1] for t in triplets]),
+ "total_volume_cbm": _sum_or_none([t[2] for t in triplets]),
+ "triplet_count": len(triplets),
+ }
+
+ separate_sum = {
+ "package_count": _sum_or_none(ctn_matches),
+ "total_weight_kg": _sum_or_none(kg_matches),
+ "total_volume_cbm": _sum_or_none(cbm_matches),
+ "ctn_count": len(ctn_matches),
+ "kg_count": len(kg_matches),
+ "cbm_count": len(cbm_matches),
+ }
+
+ return {
+ "triplet_sum": triplet_sum,
+ "separate_sum": separate_sum,
+ }
+
+ def _shipment_weight_hints(self, shipment: Dict) -> List[str]:
+ """Ключевые токены для привязки строк веса к конкретной перевозке."""
+ if not isinstance(shipment, dict):
+ return []
+ blob = " ".join(
+ str(x or "")
+ for x in (
+ shipment.get("cargo_description"),
+ shipment.get("pickup_address"),
+ shipment.get("delivery_address"),
+ shipment.get("container_type"),
+ shipment.get("hs_code"),
+ shipment.get("cargo_value"),
+ )
+ )
+ out: List[str] = []
+ # Явные коды партий/заказов.
+ for pat in (
+ r"\btrocyps[-_\s]?\d+\b",
+ r"\b[A-Za-z]{2,}[-_]\d{2,}\b",
+ r"\b[A-Za-z0-9]{3,}[-_][A-Za-z0-9]{2,}\b",
+ ):
+ for m in re.findall(pat, blob, flags=re.IGNORECASE):
+ tok = str(m).strip().lower()
+ if tok and tok not in out:
+ out.append(tok)
+ # Общие слова тоже берём, но ограниченно.
+ stop = {
+ "cargo", "weight", "gross", "net", "total", "kg", "kgs", "cbm", "ctn",
+ "груз", "вес", "брутто", "нетто", "итого", "всего", "адрес", "доставка",
+ }
+ for w in re.findall(r"[a-zA-Zа-яА-ЯёЁ0-9]{4,}", blob.lower()):
+ if w in stop:
+ continue
+ if w not in out:
+ out.append(w)
+ if len(out) >= 24:
+ break
+ return out
+
+ def _extract_preferred_weight_kg(self, raw_text: str, shipment: Dict) -> Dict[str, Any]:
+ """
+ Извлекает вес с приоритетом:
+ 1) gross/brutto
+ 2) нейтральный вес (без указания net/gross)
+ 3) net
+ И старается оставить только строки, относящиеся к текущей перевозке.
+ """
+ if not isinstance(raw_text, str) or not raw_text.strip():
+ return {"value": None, "count": 0, "priority": "none", "context_matched": False}
+
+ hints = self._shipment_weight_hints(shipment)
+ lines = [ln for ln in raw_text.splitlines() if isinstance(ln, str) and ln.strip()]
+ if not lines:
+ return {"value": None, "count": 0, "priority": "none", "context_matched": False}
+
+ num_unit_re = re.compile(
+ r"(?P\d+(?:[.,]\d+)?)\s*(?Pkgs?|kg|кг|тонн(?:а|ы)?|тонн|tons?|ton|t)\b",
+ re.IGNORECASE,
+ )
+ gross_re = re.compile(
+ r"\bgross\b|\bg\.?\s*w\.?\b|брутто|brutto|"
+ r"общий\s+вес|общ\.?\s*вес|общая\s+масса|общ\.?\s*масса",
+ re.IGNORECASE,
+ )
+ net_re = re.compile(r"\bnet\b|\bn\.?\s*w\.?\b|нетто", re.IGNORECASE)
+ total_re = re.compile(r"\btotal\b|итог|всего|grand\s*total|итого", re.IGNORECASE)
+
+ candidates: List[Dict[str, Any]] = []
+ for ln in lines:
+ ll = ln.lower()
+ if any(x in ll for x in ["контейнер", "фут", "20ft", "40ft", "40hc", "20dc"]):
+ continue
+ kind = "unknown"
+ has_gross = bool(gross_re.search(ll))
+ has_net = bool(net_re.search(ll))
+ if has_gross and not has_net:
+ kind = "gross"
+ elif has_net and not has_gross:
+ kind = "net"
+
+ ctx_score = 0
+ if hints:
+ for h in hints:
+ if h and h in ll:
+ ctx_score += 1
+
+ line_has_total = bool(total_re.search(ll))
+ for m in num_unit_re.finditer(ln):
+ raw_num = (m.group("num") or "").replace(" ", "").replace(",", ".")
+ try:
+ v = float(raw_num)
+ except Exception:
+ continue
+ unit = (m.group("unit") or "").lower()
+ if unit in ("ton", "tons", "t", "тонна", "тонны", "тонн"):
+ v *= 1000.0
+ # Отсекаем нереалистичные веса.
+ if v <= 0 or v > 5_000_000:
+ continue
+ candidates.append(
+ {
+ "value": round(v, 6),
+ "kind": kind,
+ "ctx": ctx_score,
+ "is_total": line_has_total,
+ "line": re.sub(r"\s+", " ", ln.strip())[:280],
+ }
+ )
+
+ if not candidates:
+ return {"value": None, "count": 0, "priority": "none", "context_matched": False}
+
+ # Если есть строки, привязанные к контексту текущей перевозки, берём только их.
+ context_matched = False
+ if hints:
+ max_ctx = max(int(c["ctx"]) for c in candidates)
+ if max_ctx > 0:
+ candidates = [c for c in candidates if int(c["ctx"]) == max_ctx]
+ context_matched = True
+
+ # Приоритет типа веса: gross > unknown > net.
+ for bucket_name in ("gross", "unknown", "net"):
+ bucket = [c for c in candidates if c["kind"] == bucket_name]
+ if not bucket:
+ continue
+
+ # Строки с явным словом "итого/total"
+ with_total = [c for c in bucket if c["is_total"]]
+
+ # Для группы "unknown" без явных total: суммируем все веса,
+ # так как они, скорее всего, относятся к отдельным товарным позициям.
+ # Контекстные подсказки игнорируем, чтобы не потерять часть позиций.
+ if bucket_name == "unknown" and not with_total:
+ chosen = bucket # все подходящие строки
+ context_matched = False # сигнал, что контекст не привязан к одной партии
+ else:
+ chosen = with_total if with_total else bucket
+
+ # Оставляем только записи, где упоминается "kg" или "кг" (если такие есть)
+ kg_vals = [c for c in chosen if "kg" in c["line"].lower() or "кг" in c["line"].lower()]
+ if kg_vals:
+ chosen = kg_vals
+
+ vals: List[float] = []
+ seen_values: set[float] = set()
+ for c in chosen:
+ val = float(c["value"])
+ rounded = round(val, 3) # можно и грубее, но 3 знака достаточно
+ if rounded in seen_values:
+ continue
+ seen_values.add(rounded)
+ vals.append(val)
+
+ if not vals:
+ continue
+ return {
+ "value": float(sum(vals)),
+ "count": len(vals),
+ "priority": bucket_name,
+ "context_matched": context_matched,
+ }
+
+ def _line_looks_vehicle_size_context(self, line: str) -> bool:
+ """Строка про габариты транспорта/машины — не смешивать с габаритами груза."""
+ ll = line.lower()
+ keys = (
+ "габарит машин",
+ "габариты машин",
+ "габарит тс",
+ "габариты тс",
+ "габарит транспорт",
+ "габариты транспорт",
+ "размер фуры",
+ "длина фуры",
+ "длина тента",
+ "габарит кузов",
+ "габариты кузов",
+ "габарит прицеп",
+ "габариты прицеп",
+ "габарит полуприцеп",
+ "vehicle dimension",
+ "truck dimension",
+ "dimensions of truck",
+ "размер автопоезд",
+ "высота тс",
+ "длина тс",
+ "ширина тс",
+ )
+ return any(k in ll for k in keys)
+
+ def _extract_dimensions_from_text(self, raw_text: str) -> List[Dict[str, float]]:
+ """
+ Извлекает все уникальные Д×Ш×В (длина/ширина/высота) из таблиц/строк.
+ Конвертирует мм->см по эвристике (если значения выглядят как мм).
+ """
+ if not isinstance(raw_text, str) or not raw_text.strip():
+ return []
+
+ def _to_float(v: str) -> Optional[float]:
+ if not isinstance(v, str):
+ return None
+ s = v.strip().replace(" ", "")
+ s = s.replace(",", ".")
+ try:
+ return float(s)
+ except Exception:
+ return None
+
+ # Тройка чисел через x/×/X; опционально мм/см сразу после третьего числа
+ triple_re = re.compile(
+ r"(?P\d+(?:[.,]\d+)?)\s*(?:x|×|X)\s*(?P\d+(?:[.,]\d+)?)\s*(?:x|×|X)\s*(?P\d+(?:[.,]\d+)?)"
+ r"\s*(?Pмм|mm|см|cm)?(?!\d)",
+ re.IGNORECASE,
+ )
+
+ lines = raw_text.splitlines()
+
+ def _looks_like_carton(line: str) -> bool:
+ ll = line.lower()
+ return any(k in ll for k in ["carton size", "carton", "габарит", "размер", "size", "size/ctn"])
+
+ candidates = [ln for ln in lines if _looks_like_carton(ln)]
+ if not candidates:
+ candidates = lines
+
+ dims: List[Dict[str, float]] = []
+ seen: set[tuple[float, float, float]] = set()
+
+ for ln in candidates:
+ if self._line_looks_vehicle_size_context(ln):
+ continue
+ line_unit = _line_hint_dimension_unit(ln)
+ for m in triple_re.finditer(ln):
+ a = _to_float(m.group("a"))
+ b = _to_float(m.group("b"))
+ c = _to_float(m.group("c"))
+ if a is None or b is None or c is None:
+ continue
+
+ u_raw = (m.group("u") or "").strip().lower()
+ explicit: Optional[str] = None
+ if u_raw in ("мм", "mm"):
+ explicit = "mm"
+ elif u_raw in ("см", "cm"):
+ explicit = "cm"
+ elif u_raw in ("m", "м"):
+ explicit = "m"
+ if explicit is None:
+ explicit = line_unit
+
+ l_cm, w_cm, h_cm = infer_cargo_triple_raw_to_cm(a, b, c, explicit_unit=explicit)
+
+ # Плаузибл-диапазон для габаритов единичной тары (в см).
+ if not (1 <= l_cm <= 200 and 1 <= w_cm <= 200 and 1 <= h_cm <= 200):
+ continue
+
+ l_cm = round(l_cm, 4)
+ w_cm = round(w_cm, 4)
+ h_cm = round(h_cm, 4)
+ key = (round(l_cm, 1), round(w_cm, 1), round(h_cm, 1))
+ if key in seen:
+ continue
+ seen.add(key)
+ dims.append({"length_cm": l_cm, "width_cm": w_cm, "height_cm": h_cm})
+
+ return dims
+
+ def _extract_vehicle_dimensions_from_text(self, raw_text: str) -> List[Dict[str, float]]:
+ """Тройки размеров только из строк с контекстом ТС (фура, кузов, машина)."""
+ if not isinstance(raw_text, str) or not raw_text.strip():
+ return []
+
+ def _to_float(v: str) -> Optional[float]:
+ if not isinstance(v, str):
+ return None
+ s = v.strip().replace(" ", "").replace(",", ".")
+ try:
+ return float(s)
+ except Exception:
+ return None
+
+ triple_re = re.compile(
+ r"(?P\d+(?:[.,]\d+)?)\s*(?:x|×|X)\s*(?P\d+(?:[.,]\d+)?)\s*(?:x|×|X)\s*(?P\d+(?:[.,]\d+)?)(?!\d)",
+ re.IGNORECASE,
+ )
+ lines = raw_text.splitlines()
+ out: List[Dict[str, float]] = []
+ seen: set[tuple[float, float, float]] = set()
+
+ for ln in lines:
+ if not self._line_looks_vehicle_size_context(ln):
+ continue
+ ll = ln.lower()
+ meters_hint = bool(re.search(r"\bм\b|meter|metre|\bm\s", ll))
+ for m in triple_re.finditer(ln):
+ a = _to_float(m.group("a"))
+ b = _to_float(m.group("b"))
+ c = _to_float(m.group("c"))
+ if a is None or b is None or c is None:
+ continue
+ vals = [a, b, c]
+ if meters_hint or (max(vals) <= 30 and min(vals) >= 0.25):
+ vals = [v * 100.0 for v in vals]
+ else:
+ unit_mm = max(vals) > 250
+ if unit_mm:
+ vals = [v / 10.0 for v in vals]
+ l_cm, w_cm, h_cm = vals[0], vals[1], vals[2]
+ if not (50 <= l_cm <= 3000 and 50 <= w_cm <= 400 and 50 <= h_cm <= 500):
+ continue
+ l_cm = round(l_cm, 4)
+ w_cm = round(w_cm, 4)
+ h_cm = round(h_cm, 4)
+ key = (round(l_cm, 1), round(w_cm, 1), round(h_cm, 1))
+ if key in seen:
+ continue
+ seen.add(key)
+ out.append({"length_cm": l_cm, "width_cm": w_cm, "height_cm": h_cm})
+ return out
+
+ def _postprocess_quantities_and_dimensions(
+ self,
+ shipment: Dict,
+ sources: List[Dict],
+ ) -> None:
+ """
+ Детерминированное усиление:
+ - пересчитать package_count/weight/volume по множественным упоминаниям (не брать первое)
+ - расширить dimensions всеми вариантами размеров
+ - применить эвристики отрицаний для булевых полей
+ """
+ if not isinstance(shipment, dict):
+ return
+
+ raw_text = self._collect_shipment_source_text(shipment, sources)
+ if not raw_text.strip():
+ return
+
+ quantities = self._extract_quantities_from_text(raw_text)
+ triplet_sum = quantities.get("triplet_sum") or None
+ separate_sum = quantities.get("separate_sum") or {}
+ preferred_weight = self._extract_preferred_weight_kg(raw_text, shipment)
+
+ total_shipments = getattr(self, "_postprocess_total_shipments", None)
+ single_shipment_mode = isinstance(total_shipments, int) and total_shipments == 1
+
+ def _safe_float(v):
+ if v is None:
+ return None
+
+ if isinstance(v, (int, float)):
+ return float(v)
+
+ s = str(v).strip().lower()
+
+ s = s.replace("kg", "")
+ s = s.replace("kgs", "")
+ s = s.replace("cbm", "")
+ s = s.replace("m3", "")
+ s = s.replace(",", ".")
+
+ s = re.sub(r"[^0-9.]", "", s)
+
+ try:
+ return float(s)
+ except:
+ return None
+
+ # Применить эвристики отрицаний для булевых полей
+ def _apply_boolean_negations(shipment: Dict, email_text: str):
+ """Если в тексте явно указано отрицание для булева поля, проставляем False."""
+ if not email_text or not isinstance(email_text, str):
+ return
+ low = email_text.lower()
+
+ # Явный non-DG: фиксируем все категории как "нет", а MSDS/DGM как "не нужен".
+ if re.search(
+ r"\bnon[\s\-]?dg\b|not\s+dangerous|not\s+hazardous|not\s+classified\s+as\s+dangerous|"
+ r"без\s+опасн\w+\s+груз|не\s+опасн\w+\s+груз|груз\s+не\s*опасн\w*|"
+ r"груз\s+не\s+явля\w+\s+опасн\w*|не\s+явля\w+\s+опасн\w*|неопасн\w+\s+груз|"
+ r"опасн\w+\s+груз\w*\s+нет|не\s+hazmat",
+ low,
+ re.IGNORECASE,
+ ):
+ dg = shipment.get("dangerous_goods")
+ if not isinstance(dg, dict):
+ dg = {}
+ for k in ("batteries", "gases", "liquids", "dry_ice"):
+ dg[k] = False
+ shipment["dangerous_goods"] = dg
+ shipment["msds_required"] = False
+ shipment["dgm_report_required"] = False
+ shipment.pop("dangerous_goods_note", None)
+
+ # MSDS
+ if shipment.get("msds_required") is None:
+ if re.search(
+ r"msds\s+не\s+(нуж|треб)|не\s+(нуж|треб)\w*\s+msds|паспорт\s+безопасн\w*\s+не\s+(нуж|треб)",
+ low):
+ shipment["msds_required"] = False
+ # DGM
+ if shipment.get("dgm_report_required") is None:
+ if re.search(
+ r"dgm\s+не\s+(нуж|треб)|не\s+(нуж|треб)\w*\s+dgm|декларац\w+\s+на\s+опасн\w*\s+не\s+(нуж|треб)",
+ low):
+ shipment["dgm_report_required"] = False
+ # Замена документов (уже есть отдельный метод, но можно дополнить)
+ if shipment.get("document_replacement_needed") is None:
+ if re.search(r"замен\w*\s+документ\w*\s+не\s+(нуж|треб)|не\s+(нуж|треб)\w*\s+замен\w+\s+документ", low):
+ shipment["document_replacement_needed"] = False
+ # Авторизационное письмо бренда
+ if shipment.get("brand_authorization_letter") is None:
+ if re.search(
+ r"авторизацион\w+\s+письм\w*\s+не\s+(нуж|треб)|не\s+(нуж|треб)\w*\s+авторизацион\w+\s+письм",
+ low):
+ shipment["brand_authorization_letter"] = False
+
+ _apply_boolean_negations(shipment, raw_text)
+
+ # Далее идёт оригинальный код: определения _parse_existing_num, _maybe_int
+
+ def _parse_existing_num(v: Any) -> Optional[float]:
+ if v is None:
+ return None
+ if isinstance(v, (int, float)):
+ return float(v)
+ if isinstance(v, str):
+ s = v.strip().replace(" ", "").replace(",", ".")
+ try:
+ return float(s)
+ except Exception:
+ return None
+ return None
+
+ def _maybe_int(v: Any) -> Any:
+ if v is None:
+ return None
+ if isinstance(v, (int, float)):
+ vf = float(v)
+ if abs(vf - round(vf)) < 1e-6:
+ return int(round(vf))
+ return vf
+ return v
+
+ # 1) Если найдено >=2 триплетов — считаем, что есть несколько явных частей.
+ if isinstance(triplet_sum, dict) and triplet_sum.get("triplet_count", 0) >= 2:
+ shipment["package_count"] = _maybe_int(triplet_sum.get("package_count"))
+ # Вес агрессивно пересчитываем только при одной перевозке.
+ if single_shipment_mode:
+ if preferred_weight.get("value") is not None:
+ shipment["total_weight_kg"] = float(preferred_weight.get("value"))
+ else:
+ shipment["total_weight_kg"] = triplet_sum.get("total_weight_kg")
+ shipment["total_volume_cbm"] = triplet_sum.get("total_volume_cbm")
+ else:
+ # 2) Иначе — точечно обновляем только если по отдельным полям есть много совпадений,
+ # а текущее значение меньше вычисленного.
+ for field, key_count in [
+ ("package_count", "ctn_count"),
+ ("total_volume_cbm", "cbm_count"),
+ ]:
+ computed = separate_sum.get(field)
+ match_count = separate_sum.get(key_count, 0) if isinstance(separate_sum, dict) else 0
+ if computed is None or match_count < 2:
+ continue
+
+ existing = _parse_existing_num(shipment.get(field))
+ if existing is None or (existing is not None and existing < computed * 0.99):
+ shipment[field] = _maybe_int(computed) if field == "package_count" else computed
+
+ # Вес обновляем отдельно ТОЛЬКО при одной перевозке:
+ # при нескольких партиях в треде веса часто уже разнесены по строкам/таблицам.
+ if single_shipment_mode:
+ # 2а) Сумма отдельных kg-совпадений (если их много) как fallback.
+ kg_sum = separate_sum.get("total_weight_kg") if isinstance(separate_sum, dict) else None
+ kg_count = int((separate_sum or {}).get("kg_count", 0)) if isinstance(separate_sum, dict) else 0
+ if kg_sum is not None and kg_count >= 2:
+ existing_w = _parse_existing_num(shipment.get("total_weight_kg"))
+ if existing_w is None or (existing_w is not None and existing_w < float(kg_sum) * 0.99):
+ shipment["total_weight_kg"] = float(kg_sum)
+
+ # 2б) Предпочтительный вес (gross/контекст) приоритетнее fallback.
+ w = preferred_weight.get("value")
+ w_cnt = int(preferred_weight.get("count") or 0)
+ if w is not None and w_cnt >= 1:
+ existing_w = _parse_existing_num(shipment.get("total_weight_kg"))
+ # Не занижаем уже найденный общий вес одиночным "предпочтительным" значением
+ # (часто это вес одной палеты/позиции без маркера total).
+ should_apply = (
+ existing_w is None
+ or float(w) >= float(existing_w) * 0.99
+ or w_cnt >= 2
+ )
+ if should_apply and (
+ existing_w is None or abs(existing_w - float(w)) > max(1.0, float(w) * 0.01)
+ ):
+ shipment["total_weight_kg"] = float(w)
+ else:
+ # Для нескольких перевозок обновляем вес только при явной контекстной привязке
+ # к конкретной партии (код/токены shipment в строке веса).
+ w = preferred_weight.get("value")
+ w_cnt = int(preferred_weight.get("count") or 0)
+ ctx_ok = bool(preferred_weight.get("context_matched"))
+ if ctx_ok and w is not None and w_cnt >= 1:
+ existing_w = _parse_existing_num(shipment.get("total_weight_kg"))
+ if existing_w is None or abs(existing_w - float(w)) > max(1.0, float(w) * 0.01):
+ shipment["total_weight_kg"] = float(w)
+
+ # 3) Габариты
+ # ВАЖНО:
+ # - не схлопываем одинаковые места
+ # - не смешиваем regex dimensions с LLM dimensions
+ # - сохраняем каждое физическое место отдельно
+ existing_dims = shipment.get("dimensions")
+ if isinstance(existing_dims, list) and existing_dims:
+ dims = existing_dims
+ else:
+ dims = self._extract_dimensions_from_text(raw_text)
+ out = []
+ for idx, d in enumerate(dims, start=1):
+ if not isinstance(d, dict):
+ continue
+ # безопасное приведение к float с поддержкой None
+ def _safe_float(val):
+ if val is None:
+ return None
+ if isinstance(val, (int, float)):
+ return float(val)
+ s = str(val).strip().replace(" ", "").replace(",", ".")
+ try:
+ return float(s)
+ except Exception:
+ return None
+
+ l_f = _safe_float(d.get("length_cm"))
+ w_f = _safe_float(d.get("width_cm"))
+ h_f = _safe_float(d.get("height_cm"))
+
+ # хотя бы одно измерение должно быть положительным
+ if (l_f is None or l_f <= 0) and (w_f is None or w_f <= 0) and (h_f is None or h_f <= 0):
+ continue
+
+ # формируем нормализованную запись
+ norm = {"place_no": idx}
+ if l_f is not None and l_f > 0:
+ norm["length_cm"] = l_f
+ if w_f is not None and w_f > 0:
+ norm["width_cm"] = w_f
+ if h_f is not None and h_f > 0:
+ norm["height_cm"] = h_f
+
+ weight_kg = _safe_float(d.get("weight_kg"))
+ if weight_kg is not None and weight_kg > 0:
+ norm["weight_kg"] = round(weight_kg, 4)
+ volume_cbm = _safe_float(d.get("volume_cbm"))
+ if volume_cbm is not None and volume_cbm > 0:
+ norm["volume_cbm"] = round(volume_cbm, 6)
+
+ out.append(norm)
+
+ shipment["dimensions"] = out
+
+ vdims = self._extract_vehicle_dimensions_from_text(raw_text)
+ if vdims:
+ existing_v = shipment.get("vehicle_dimensions")
+ if not isinstance(existing_v, list):
+ existing_v = []
+ merged_v = existing_v + vdims
+ seen_v: set[tuple[float, float, float]] = set()
+ out_v: List[Dict[str, float]] = []
+ for d in merged_v:
+ if not isinstance(d, dict):
+ continue
+ l = d.get("length_cm")
+ w = d.get("width_cm")
+ h = d.get("height_cm")
+ if l is None or w is None or h is None:
+ out_v.append(d)
+ continue
+ try:
+ l_f = float(str(l).replace(" ", "").replace(",", "."))
+ w_f = float(str(w).replace(" ", "").replace(",", "."))
+ h_f = float(str(h).replace(" ", "").replace(",", "."))
+ except Exception:
+ out_v.append(d)
+ continue
+ key = (round(l_f, 1), round(w_f, 1), round(h_f, 1))
+ if key in seen_v:
+ continue
+ seen_v.add(key)
+ out_v.append({"length_cm": l_f, "width_cm": w_f, "height_cm": h_f})
+ shipment["vehicle_dimensions"] = out_v
+
+ def _enrich_operator_document_services(self, shipment: Dict, sources: List[Dict]) -> None:
+ """
+ Если в письме явно указано, что документ не предоставляет клиент/контрагент,
+ добавляем в additional_services строку о подготовке документа силами оператора.
+ """
+ if not isinstance(shipment, dict):
+ return
+ raw_text = self._collect_shipment_source_text(shipment, sources)
+ if not raw_text.strip():
+ return
+ text_l = raw_text.lower()
+
+ docs = shipment.get("documents_found", {}) or {}
+ required_missing = {
+ "msds": shipment.get("msds_required") is True and not (docs.get("msds") or []),
+ "dgm": shipment.get("dgm_report_required") is True and not (docs.get("dgm") or []),
+ "brand_authorization": shipment.get("brand_authorization_letter") is True and not (
+ docs.get("brand_authorization") or []),
+ }
+
+ # Отдельно учитываем формулировки "документ запрошен", это НЕ "не предоставят".
+ msds_requested = bool(re.search(
+ r"\bmsds\b.{0,24}\b(запрош\w*|requested|request)\b|\b(запрош\w*|requested|request)\b.{0,24}\bmsds\b",
+ text_l,
+ re.IGNORECASE,
+ ))
+
+ actors = r"(клиент|заказчик|покупател\w*|контрагент\w*|поставщик\w*|отправител\w*|грузоотправител\w*)"
+ neg_verbs = r"(не\s+(?:предостав\w*|высыла\w*|пришл\w*|прикладыва\w*|имеет\w*|в\s+наличии))"
+ doc_words = {
+ "msds": r"(?:\bmsds\b|паспорт\w*\s+безопасност\w*|material\s+safety\s+data\s+sheet|safety\s+data\s+sheet)",
+ "dgm": r"(?:\bdgm\b|dangerous\s+goods|декларац\w*\s+на\s+опасн|imdg)",
+ "brand_authorization": r"(?:авторизацион\w*\s+письм\w*|authorization\s+letter|authorisation\s+letter|письм\w*\s+бренд)",
+ }
+
+ def _doc_not_provided(doc_key: str) -> bool:
+ dpat = doc_words[doc_key]
+ # "поставщик не предоставляет <документ>" или "<документ> не предоставляет(ся)"
+ p1 = rf"{actors}\b.{{0,45}}?{neg_verbs}\b.{{0,45}}?{dpat}"
+ p2 = rf"{dpat}.{{0,45}}?{neg_verbs}"
+ return bool(re.search(p1, text_l, re.IGNORECASE) or re.search(p2, text_l, re.IGNORECASE))
+
+ doc_not_provided = {
+ "msds": _doc_not_provided("msds"),
+ "dgm": _doc_not_provided("dgm"),
+ "brand_authorization": _doc_not_provided("brand_authorization"),
+ }
+
+ # Защита от ложного срабатывания: "MSDS запрошен" без отрицания.
+ if msds_requested and not doc_not_provided["msds"]:
+ required_missing["msds"] = False
+
+ # Добавляем пункты в additional_services только если одновременно:
+ # (1) по данным перевозки этот документ нужен и не приложен, и
+ # (2) в тексте писем явно сказано, что КОНКРЕТНО ЭТОТ документ не предоставляет клиент/поставщик.
+ # Раньше использовалась общая фраза «документы не предоставляются» — из‑за неё подтягивались
+ # и MSDS, и DGM, и письмо бренда разом; отдельно DGM не должен включать строку про MSDS.
+ additions: List[str] = []
+ if required_missing["msds"] and doc_not_provided["msds"]:
+ additions.append(
+ "Получение/подготовка MSDS силами оператора (по письму документ не предоставляется клиентом/поставщиком)"
+ )
+ if required_missing["dgm"] and doc_not_provided["dgm"]:
+ additions.append(
+ "Подготовка DGM / декларации на опасный груз силами оператора (по письму документ не от клиента/поставщика)"
+ )
+ if required_missing["brand_authorization"] and doc_not_provided["brand_authorization"]:
+ additions.append(
+ "Получение авторизационного письма бренда силами оператора (по письму документ не от клиента/поставщика)"
+ )
+
+ if not additions:
+ return
+
+ extras = shipment.get("additional_services")
+ if not isinstance(extras, list):
+ extras = []
+ seen = {str(x).strip().lower() for x in extras if str(x).strip()}
+ for line in additions:
+ key = line.strip().lower()
+ if key not in seen:
+ extras.append(line)
+ seen.add(key)
+ shipment["additional_services"] = extras
+
+ def get_attachment(self, session_id: str, email_index: int, attachment_index: int) -> Optional[Dict]:
+ """Возвращает информацию о вложении включая оригинальное содержимое"""
+ if session_id not in self.sessions:
+ return None
+ emails = self.sessions[session_id]
+ if email_index < 0 or email_index >= len(emails):
+ return None
+ attachments = emails[email_index].get("attachments", [])
+ if attachment_index < 0 or attachment_index >= len(attachments):
+ return None
+ return attachments[attachment_index]
+
+ def _extract_email_content(self, email: Dict) -> tuple:
+ parts = [
+ f"От: {email.get('senderName', '')} ({email.get('sender', '')})",
+ f"Тема: {email.get('subject', '')}",
+ f"Кому: {email.get('to', '')}",
+ f"Дата: {email.get('receivedTime', '')}",
+ f"Содержание: {email.get('body', '')}"
+ ]
+ attachments_list = []
+
+ attachments = email.get('attachments', [])
+ if attachments:
+ parts.append("Вложения:")
+ for att in attachments:
+ filename = att.get('filename', 'unknown')
+ size = att.get('size', 0)
+ content_b64 = att.get('content')
+ att_text = ""
+
+ if content_b64:
+ try:
+ file_content = base64.b64decode(content_b64)
+ extracted_text = self.doc_processor.extract_text(file_content, filename)
+ att_text = extracted_text if isinstance(extracted_text, str) else ""
+ if att_text.strip():
+ parts.append(f"- {filename} ({size} байт):\n{att_text}")
+ else:
+ parts.append(
+ f"- {filename} ({size} байт): (извлекаемого текста нет — например изображение; "
+ f"учитывайте имя файла и тело письма)"
+ )
+ except Exception as e:
+ logger.error(f"Failed to extract text from attachment {filename}: {e}")
+ parts.append(f"- {filename} ({size} байт) — ошибка извлечения текста")
+ else:
+ parts.append(f"- {filename} ({size} байт) — содержимое не передано")
+
+ attachments_list.append(
+ {"filename": filename, "text": att_text, "size": size, "content_base64": content_b64}
+ )
+
+ return "\n".join(parts), attachments_list
+
+ def _two_pass_preflight_enabled(self) -> bool:
+ """Второй проход: RAG_TWO_PASS_LLM=0|false|off отключает."""
+ raw = os.getenv("RAG_TWO_PASS_LLM", "1").strip().lower()
+ if raw in ("", "1", "true", "yes", "on", "enable", "enabled"):
+ return True
+ if raw in ("0", "false", "no", "off", "disable", "disabled"):
+ return False
+ return True
+
+ def _run_preflight_classification(
+ self,
+ context_text: str,
+ query_text: str,
+ type_names: List[str],
+ ) -> Optional[Dict[str, Any]]:
+ """
+ Проход 1: короткая классификация (режим FCL/LCL, модальности, подсказка shipping_type).
+ Результат вставляется во второй проход как orientирующая подсказка, не как истина.
+ """
+ if not (context_text and str(context_text).strip()) or context_text.strip() == "Нет писем для анализа.":
+ return None
+ max_ch = int(os.getenv("RAG_PREFLIGHT_MAX_CHARS", "48000"))
+ ctx = context_text if len(context_text) <= max_ch else (
+ context_text[:max_ch] + f"\n... [обрезано для preflight, RAG_PREFLIGHT_MAX_CHARS={max_ch}]"
+ )
+ model = os.getenv("RAG_PREFLIGHT_MODEL", "").strip() or "deepseek-v4-flash"
+ # max_tok = int(os.getenv("RAG_PREFLIGHT_MAX_TOKENS", "1200"))
+
+ system_prompt = """Ты классификатор логистической переписки. Верни ТОЛЬКО JSON-объект без markdown и лишнего текста.
+
+Верни только эти поля:
+- load_mode: FCL | LCL | road_LTL | road_FTL | air | not_container | unknown
+- multimodal_sea_rail: true/false (true только для одной связанной цепочки море+ж/д; альтернативы "море ИЛИ ж/д" = false)
+- shipping_type_suggestion: ровно одно имя из переданного списка допустимых типов или ""
+- shipment_type_if_container: "FCL" | "LCL" | ""
+- indicative_all_modes: true/false (true, если явно просят индикатив по нескольким модальностям сразу)
+
+Правила:
+- Не придумывай факты.
+- При сомнении: load_mode=unknown, shipping_type_suggestion="", shipment_type_if_container="", confidence="low".
+- FCL/LCL — только контейнерная морская/жд тема; FTL/LTL — только автодорога.
+- Если multimodal_sea_rail=true, shipping_type_suggestion должна быть мультимодальной (а не чисто морской/жд)."""
+
+ clean_ctx = ctx
+
+ markers_to_cut = [
+ "=== ATTACHMENTS ===",
+ "=== ВЛОЖЕНИЯ ===",
+ "=== PDF CONTENT ===",
+ "=== OCR ===",
+ "=== EXTRACTED ATTACHMENT TEXT ===",
+ ]
+
+ for marker in markers_to_cut:
+ if marker in clean_ctx:
+ clean_ctx = clean_ctx.split(marker)[0]
+
+ # hard-limit для preflight
+ clean_ctx = clean_ctx[:12000]
+
+ names_lines = "\n".join(str(n) for n in type_names if n)
+
+ user_content = (
+ "Допустимые значения shipping_type_suggestion "
+ "(ровно одна строка из списка ниже):\n"
+ + names_lines
+ + "\n\n--- ТЕКСТ ПИСЕМ БЕЗ ВЛОЖЕНИЙ ---\n"
+ + clean_ctx
+ + "\n\n---\nДополнительный запрос аналитика: "
+ + (query_text or "")
+ )
+
+ try:
+ response = self.openai_client.chat.completions.create(
+ model=model,
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_content},
+ ],
+ temperature=0,
+ # max_tokens=max_tok,
+ # extra_body={
+ # "thinking": False
+ # }
+ )
+ raw = (response.choices[0].message.content or "").strip()
+ parsed = self._parse_json_response(raw)
+ if not isinstance(parsed, dict):
+ return None
+ # нормализация имени типа
+ sug = parsed.get("shipping_type_suggestion")
+ if isinstance(sug, str) and sug.strip() and type_names:
+ if sug.strip() not in type_names:
+ # мягкое сопоставление без переопределения списка
+ logger.warning(
+ "Preflight shipping_type_suggestion %r не из списка — обнуляем",
+ sug.strip(),
+ )
+ parsed["shipping_type_suggestion"] = ""
+ return parsed
+ except Exception as e:
+ logger.warning("Preflight classification failed: %s", e, exc_info=True)
+ return None
+
+ def _select_shipping_types_for_prompt(
+ self,
+ context_text: str,
+ query_text: str,
+ preflight: Optional[Dict[str, Any]],
+ ) -> List[Dict]:
+ """
+ Уменьшает блок типов перевозок в промпте: оставляет top-K по совпадению keywords с текстом
+ (письма + запрос), плюс тип из preflight.shipping_type_suggestion если есть.
+ Если совпадений нет или запрошены все модальности — возвращает полный список.
+ """
+ k = _shipping_types_prompt_k()
+ all_types = [st for st in self.shipping_types if isinstance(st, dict)]
+ if k <= 0 or not all_types:
+ return all_types
+ if preflight and preflight.get("indicative_all_modes") is True:
+ logger.info("RAG_SHIPPING_TYPES_PROMPT_K: indicative_all_modes — все типы в промпте")
+ return all_types
+
+ text = f"{context_text}\n{query_text or ''}".lower()
+ scored: List[tuple[int, str, Dict]] = []
+ for st in all_types:
+ name = str(st.get("name") or "")
+ score = 0
+ for kw in st.get("keywords") or []:
+ if not isinstance(kw, str):
+ continue
+ kws = kw.strip().lower()
+ if len(kws) >= 2 and kws in text:
+ score += 1
+ scored.append((score, name, st))
+ scored.sort(key=lambda x: (-x[0], x[1]))
+
+ if not scored or scored[0][0] == 0:
+ logger.info(
+ "RAG_SHIPPING_TYPES_PROMPT_K=%d: нет совпадений keywords — все %d типов в промпте",
+ k,
+ len(all_types),
+ )
+ return all_types
+
+ selected: List[Dict] = []
+ picked: set[str] = set()
+ sug = (preflight or {}).get("shipping_type_suggestion")
+ if isinstance(sug, str) and sug.strip():
+ for sc, nm, st in scored:
+ if nm == sug.strip():
+ selected.append(st)
+ picked.add(nm)
+ break
+ for sc, nm, st in scored:
+ if len(selected) >= k:
+ break
+ if nm in picked:
+ continue
+ selected.append(st)
+ picked.add(nm)
+
+ logger.info(
+ "RAG_SHIPPING_TYPES_PROMPT_K=%d: в промпте %d из %d типов перевозок",
+ k,
+ len(selected),
+ len(all_types),
+ )
+ return selected
+
+ def _collect_session_sources(self, session_id: Optional[str]) -> tuple[str, List[Dict]]:
+ """Собирает context_text и sources для сессии (тот же формат, что и для LLM)."""
+ if session_id and session_id in self.sessions:
+ emails = self.sessions[session_id]
+ logger.info(f"Session {session_id} has {len(emails)} emails")
+ email_texts = []
+ sources: List[Dict] = []
+ for idx, email in enumerate(emails, 1):
+
+ email_id = f"{session_id}_{idx}"
+
+ metadata = email.get("metadata", {}) or {}
+
+ subject = (
+ metadata.get("subject")
+ or metadata.get("title")
+ or ""
+ )
+
+ content = email.get("content", "") or ""
+
+ # Формируем расширенный текст письма
+ email_parts = [
+ f"ID письма: {email_id}"
+ ]
+
+ if subject.strip():
+ email_parts.append(f"SUBJECT: {subject}")
+
+ email_parts.append(content)
+
+ email_texts.append("\n".join(email_parts))
+
+ sources.append({
+ "id": email_id,
+
+ **metadata,
+
+ "subject": subject,
+
+ "content": content,
+
+ "attachments": email.get("attachments", [])
+ })
+ context_text = "\n\n---\n\n".join(email_texts)
+ logger.info(f"Context preview: {context_text[:300]}...")
+ return context_text, sources
+ if session_id:
+ logger.warning(f"Session {session_id} not found")
+ return "Нет писем для анализа.", []
+
+ def _session_content_fingerprint(self, session_id: str) -> Optional[str]:
+ """
+ Отпечаток набора писем в сессии: id письма + хэш текста (включая вложения в content).
+ Меняется при правке shipping_types.json (mtime) и RAG_CACHE_BUST.
+ """
+ if session_id not in self.sessions:
+ return None
+ emails = self.sessions[session_id]
+ parts: List[str] = []
+ for email in emails:
+ meta = email.get("metadata") or {}
+ eid = str(meta.get("email_id") or "")
+ content = email.get("content") or ""
+ h = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()
+ parts.append(f"{eid}:{h}")
+ parts.sort()
+ st_path = resolve_shipping_types_path()
+ st_tag = ""
+ if st_path and os.path.isfile(st_path):
+ st_tag = str(int(os.path.getmtime(st_path)))
+ code_tags: List[str] = []
+ for p in (
+ __file__,
+ os.path.join(_SHIPPING_DIR, "document_processor.py"),
+ os.path.join(_SHIPPING_DIR, "document_processor(1).py"),
+ ):
+ try:
+ if p and os.path.isfile(p):
+ code_tags.append(f"{os.path.basename(p)}:{int(os.path.getmtime(p))}")
+ except Exception:
+ continue
+ bust = _report_cache_bust_token()
+ raw = (
+ f"sid:{session_id}\n"
+ + "\n".join(parts)
+ + "\nST_mtime:"
+ + st_tag
+ + "\nCODE:"
+ + "|".join(code_tags)
+ + "\nBUST:"
+ + bust
+ )
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
+
+ def _report_cache_file_path(self, session_id: str, query_text: str) -> Optional[str]:
+ fp = self._session_content_fingerprint(session_id)
+ if not fp:
+ return None
+ key = hashlib.sha256((fp + "\n" + (query_text or "")).encode("utf-8")).hexdigest()
+ return os.path.join(_report_cache_dir(), f"{key}.json")
+
+ def _try_load_cached_report(self, session_id: Optional[str], query_text: str) -> Optional[Dict]:
+ if not session_id or not _report_cache_enabled():
+ return None
+ path = self._report_cache_file_path(session_id, query_text)
+ if not path or not os.path.isfile(path):
+ return None
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ payload = json.load(f)
+ except Exception as e:
+ logger.warning("Cargo report cache read failed: %s", e)
+ return None
+ if payload.get("version") != 1:
+ return None
+ _, sources = self._collect_session_sources(session_id)
+ logger.info(
+ "Cargo report cache HIT (session=%s, emails=%d)",
+ session_id,
+ len(sources),
+ )
+ return self._normalize_api_report_payload({
+ "answer": payload.get("answer") or "",
+ "structured_data": payload.get("structured_data") or {},
+ "sources": sources,
+ "total_emails_analyzed": len(sources),
+ })
+
+ def _save_cached_report(self, session_id: Optional[str], query_text: str, result: Dict) -> None:
+ if not session_id or not _report_cache_enabled():
+ return
+ path = self._report_cache_file_path(session_id, query_text)
+ if not path:
+ return
+ payload = {
+ "version": 1,
+ "answer": result.get("answer"),
+ "structured_data": result.get("structured_data"),
+ "total_emails_analyzed": result.get("total_emails_analyzed"),
+ }
+ try:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump(payload, f, ensure_ascii=False)
+ logger.info("Cargo report cache saved: %s", path)
+ except Exception as e:
+ logger.warning("Cargo report cache write failed: %s", e)
+
+ def _normalize_api_report_payload(self, result: Optional[Dict]) -> Dict:
+ """Единый формат ответа для UI: structured_data.shipments — всегда список словарей."""
+ if not isinstance(result, dict):
+ return {
+ "answer": "",
+ "structured_data": {"shipments": []},
+ "sources": [],
+ "total_emails_analyzed": 0,
+ }
+ sd = result.get("structured_data")
+ if not isinstance(sd, dict):
+ result["structured_data"] = self._ensure_valid_structure({})
+ else:
+ result["structured_data"] = self._ensure_valid_structure(sd)
+ sd2 = result.get("structured_data")
+ if (
+ isinstance(sd2, dict)
+ and isinstance(sd2.get("shipments"), list)
+ and len(sd2["shipments"]) > 0
+ ):
+ sd2.pop("parse_error", None)
+ sd2.pop("raw_preview", None)
+ if not isinstance(result.get("sources"), list):
+ result["sources"] = []
+ te = result.get("total_emails_analyzed")
+ if not isinstance(te, int):
+ try:
+ result["total_emails_analyzed"] = int(te) if te is not None else len(result["sources"])
+ except (TypeError, ValueError):
+ result["total_emails_analyzed"] = len(result["sources"])
+ return result
+
+ def _read_learning_rows(self) -> List[Dict[str, Any]]:
+ path = _learning_store_path()
+ if not os.path.isfile(path):
+ return []
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ except Exception as e:
+ logger.warning("Cargo learning store read failed: %s", e)
+ return []
+ rows = data.get("examples") if isinstance(data, dict) else None
+ if not isinstance(rows, list):
+ return []
+ return [r for r in rows if isinstance(r, dict)]
+
+ def _write_learning_rows(self, rows: List[Dict[str, Any]]) -> None:
+ path = _learning_store_path()
+ try:
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump({"version": 1, "examples": rows}, f, ensure_ascii=False, indent=2)
+ except Exception as e:
+ logger.warning("Cargo learning store write failed: %s", e)
+
+ def _learning_few_shot_system_fragment(self, context_text: str) -> str:
+ if not _learning_few_shot_enabled():
+ return ""
+ n = _learning_few_shot_count()
+ if n <= 0:
+ return ""
+ rows = self._read_learning_rows()
+ if not rows:
+ return ""
+ ctx_words = _context_word_set((context_text or "")[:12000])
+ scored: List[tuple] = []
+ for r in rows:
+ prev = r.get("context_preview") or ""
+ ew = _context_word_set(prev[:12000])
+ score = len(ctx_words & ew) if ctx_words else 0
+ scored.append((score, str(r.get("created_at") or ""), r))
+ scored.sort(key=lambda t: (-t[0], t[1]))
+ picked = [t[2] for t in scored[:n] if t[0] > 0]
+ if len(picked) < n:
+ recent = sorted(rows, key=lambda x: str(x.get("created_at") or ""), reverse=True)
+ for r in recent:
+ if r not in picked:
+ picked.append(r)
+ if len(picked) >= n:
+ break
+ blocks: List[str] = []
+ cap = 3500
+ for i, ex in enumerate(picked[:n], start=1):
+ blob = ex.get("structured_data_compact")
+ if not isinstance(blob, dict):
+ continue
+ js = _json_prompt_compact(blob)
+ if len(js) > cap:
+ js = js[:cap] + "…"
+ blocks.append(
+ f"Пример {i} (фрагмент эталонного JSON по похожей переписке):\n{js}"
+ )
+ if not blocks:
+ return ""
+ return (
+ "\n\nОПЫТ ПО ПРОШЛЫМ РАЗБОРАМ (ориентир по формату и полям, не копируй данные если в текущих письмах иначе):\n"
+ + "\n\n".join(blocks)
+ + "\n\n"
+ )
+
+ def _learning_dedupe_hash(self, context_text: str, structured_data: Dict) -> str:
+ sd = structured_data if isinstance(structured_data, dict) else {}
+ ship = sd.get("shipments")
+ try:
+ payload = json.dumps(ship, sort_keys=True, ensure_ascii=False, default=str)
+ except Exception:
+ payload = str(ship)
+ raw = (context_text or "")[:8000] + "\n" + payload
+ return hashlib.sha256(raw.encode("utf-8", errors="replace")).hexdigest()
+
+ def record_cargo_learning(
+ self,
+ *,
+ structured_data: Dict,
+ context_preview: Optional[str] = None,
+ session_id: Optional[str] = None,
+ notes: Optional[str] = None,
+ ) -> bool:
+ """
+ Сохраняет пару «фрагмент переписки → структурированный ответ» для few-shot обучения.
+ Можно вызывать после ручной правки JSON в UI (передайте исправленный structured_data).
+ """
+ if not isinstance(structured_data, dict):
+ return False
+ sd = self._ensure_valid_structure(structured_data.copy())
+ ctx = context_preview if isinstance(context_preview, str) else ""
+ if not ctx.strip() and session_id and session_id in self.sessions:
+ ct, _ = self._collect_session_sources(session_id)
+ ctx = ct
+ ctx = (ctx or "")[:24000]
+ if len(ctx.strip()) < 80 and not sd.get("shipments"):
+ logger.info("Cargo learning: skip (no context and empty shipments)")
+ return False
+ h = self._learning_dedupe_hash(ctx, sd)
+ rows = self._read_learning_rows()
+ if any(isinstance(r, dict) and r.get("dedupe_hash") == h for r in rows):
+ logger.info("Cargo learning: duplicate hash, skip")
+ return False
+ context_text, sources = self._collect_session_sources(session_id)
+
+ cleaned_emails = _clean_emails_for_learning(sources)
+
+ row = {
+ "input": {
+ "emails": cleaned_emails,
+ "context_text": context_text
+ },
+ "output": {
+ "structured_data": structured_data,
+ "client_letter": structured_data.get("generated_letter")
+ },
+ "meta": {
+ "session_id": session_id,
+ "created_at": datetime.now().isoformat(),
+ "quality": "auto"
+ }
+ }
+ rows.append(row)
+ max_n = _learning_max_store()
+ if len(rows) > max_n:
+ rows = rows[-max_n:]
+ self._write_learning_rows(rows)
+ logger.info("Cargo learning: stored example (%d total)", len(rows))
+ return True
+
+ def _maybe_auto_record_learning(
+ self, session_id: Optional[str], context_text: str, result: Dict
+ ) -> None:
+ if not _learning_auto_enabled():
+ return
+ sd = result.get("structured_data")
+ if not isinstance(sd, dict):
+ return
+ if sd.get("parse_error") and not sd.get("shipments"):
+ return
+ try:
+ self.record_cargo_learning(
+ structured_data=sd,
+ context_preview=context_text,
+ session_id=session_id,
+ notes="auto_after_analysis",
+ )
+ except Exception as e:
+ logger.warning("Cargo learning auto-record failed: %s", e)
+
+ async def query_cargo_info(self, query_text: str, session_id: Optional[str] = None, top_k: int = 10) -> Dict:
+ context_text, sources = self._collect_session_sources(session_id)
+
+ cached = self._try_load_cached_report(session_id, query_text)
+ if cached is not None:
+ return cached
+
+ if _email_thread_dedupe_enabled():
+ context_text = _dedupe_email_thread_paragraphs(context_text)
+
+ max_ctx = _max_llm_context_chars()
+ if max_ctx > 0 and len(context_text) > max_ctx:
+ context_text = (
+ context_text[:max_ctx]
+ + f"... [текст обрезан: RAG_MAX_CONTEXT_CHARS={max_ctx}]"
+ )
+
+ type_names = [st.get("name") for st in self.shipping_types if st.get("name")]
+ preflight: Optional[Dict[str, Any]] = None
+ if self._two_pass_preflight_enabled():
+ preflight = self._run_preflight_classification(
+ context_text, query_text or "", type_names
+ )
+ if preflight:
+ logger.info("Preflight LLM pass completed (keys=%s)", list(preflight.keys()))
+
+ crit_max = _criteria_preview_chars()
+ man_max = _mandatory_counterparty_chars()
+ types_for_prompt = self._select_shipping_types_for_prompt(
+ context_text, query_text or "", preflight
+ )
+ shipping_types_info = []
+ for st in types_for_prompt:
+ shipping_types_info.append({
+ "name": st.get("name"),
+ "keywords": st.get("keywords", []) or [],
+ "criteria_preview": _limit_prompt_field(st.get("criteria") or "", crit_max),
+ "mandatory_counterparty_criteria": _limit_prompt_field(
+ st.get("mandatory_counterparty_criteria") or "", man_max
+ ),
+ })
+ shipping_types_json_compact = _json_prompt_compact(shipping_types_info)
+
+ preflight_block = ""
+ if preflight:
+ preflight_block = (
+ "\n\nПРЕДВАРИТЕЛЬНЫЙ ПРОХОД 1 — КЛАССИФИКАЦИЯ (отдельный короткий запрос к LLM до полного извлечения; ориентир, "
+ "проверь по фактам в письмах ниже):\n"
+ + _json_prompt_compact(preflight)
+ + "\n\nИспользование: учти load_mode, shipment_type_if_container и shipping_type_suggestion "
+ "при заполнении shipment_type и shipping_type. "
+ "Если письма или вложения противоречат этому блоку — приоритет у текста писем/вложений. "
+ "При confidence=low или пустом shipping_type_suggestion не заполняй shipping_type без явных признаков.\n"
+ )
+
+ container_iso_ref_block = iso_reference_prompt_block()
+ if container_iso_ref_block:
+ container_iso_ref_block = "\n\n" + container_iso_ref_block + "\n"
+
+ learning_fragment = self._learning_few_shot_system_fragment(context_text)
+
+ system_prompt = f"""Ты - эксперт по логистике и грузоперевозкам. Твоя задача - проанализировать предоставленные письма и извлечь ВСЮ доступную информацию о грузоперевозках в строгом JSON-формате.
+Твоя задача — максимально точно извлечь данные из писем и вложений.
+{learning_fragment}
+КЛЮЧЕВЫЕ ПРАВИЛА:
+- Не додумывай факты. Если данных нет: null для чисел/неизвестных булевых, "" для строк, [] для списков.
+- Приоритет источников: основной текст письма > вложения > подписи/дисклеймеры (игнорировать).
+- Несколько shipments делай только при явном разделении на независимые партии/маршруты; иначе один shipment.
+- Не выводи рассуждения.
+- Запрещено логически предполагать штабелируемость груза.
+- stackable_with_others и stackable_among_themselves можно ставить true или false ТОЛЬКО при явном указании в письме, packing list или вложениях.
+- Если штабелируемость явно не указана — обязательно ставь null.
+- Не делай вывод "одежда/коробки/паллеты обычно штабелируются".
+PRE-FLIGHT:
+- Блок preflight ниже — только подсказка, не источник истины.
+- Если preflight противоречит письмам/вложениям, приоритет у писем/вложений.
+- Используй preflight только как ориентир для `shipping_type` и `shipment_type`, без дублирования его вывода.
+{preflight_block}
+ИСПОЛЬЗУЙ ТОЛЬКО ЭТИ ТИПЫ:
+{shipping_types_json_compact}
+ВЫБОР ТИПОВ ПЕРЕВОЗКИ:
+- `requested_shipping_type_names`: массив точных `name` из списка выше, которые явно запрошены/сравниваются для этой партии.
+- Один однозначный способ -> массив из одного имени.
+- Если клиент просит все/любой варианты -> перечисли все релевантные имена из списка.
+- Если явных признаков способа нет -> `requested_shipping_type_names` = [] и `shipping_type` = "".
+- `shipping_type` заполняй только когда в `requested_shipping_type_names` ровно один элемент, иначе "".
+- Мультимодальность море+ж/д ставь только для одной связанной цепочки маршрута; варианты "море ИЛИ ж/д" — это не мультимодальность.
+- FCL/LCL — контейнерная тема море/жд; FTL/LTL — автодорога.
+СОГЛАСОВАНИЕ ПОЛЕЙ:
+- `shipment_type`: только "FCL" | "LCL" | ""; при явном (FCL)/(LCL) в `shipping_type` согласуй значение.
+- `container_type` — для контейнерной море/жд темы; `vehicle_type` и `vehicle_dimensions` — для автодороги.
+- `shipping_options` заполняй только явно упомянутыми вариантами, без выдуманных цен/сроков.
+- `document_replacement_needed`: true/false только по явной формулировке, иначе null.
+- В `dangerous_goods` ставь true/false только при явном подтверждении/отрицании, иначе null.
+- Если в письме явно сказано, что груз НЕ опасный (non-DG / not dangerous / не опасный), проставь `dangerous_goods.batteries/gases/liquids/dry_ice = false`, а также `msds_required = false` и `dgm_report_required = false`.
+- Если в письме/вложениях есть габариты палет/коробок/грузовых мест — ОБЯЗАТЕЛЬНО заполни `dimensions` даже если также указаны контейнеры/машины/вес.
+{container_iso_ref_block}
+ТРЕБУЕМЫЙ ФОРМАТ ОТВЕТА (ТОЛЬКО JSON):
+{{
+ "shipments": [
+ {{
+ "ID_emails": ["список ID писем, откуда взята информация"],
+ "client_name": "название компании-клиента",
+ "incoterms": "условия поставки Incoterms",
+ "cargo_ready_date": "дата(ы) готовности груза к отгрузке: одна строка или массив строк; если для разных мест/партий указаны разные даты — перечисли все (позже отобразятся через запятую)",
+ "pickup_address": "адрес забора груза",
+ "cargo_value": "стоимость груза с валютой",
+ "package_count": 10,
+ "total_weight_kg": 150.5,
+ "dimensions": [
+ {{"place_no","length_cm": 100, "width_cm": 80, "height_cm": 60, "dimension_unit": "cm", "weight_kg": 250, "volume_cbm": 0.48}}
+ ],
+ "vehicle_dimensions": [
+ {{"length_cm": 1360, "width_cm": 245, "height_cm": 270}}
+ ],
+ "total_volume_cbm": 2.5,
+ "cargo_description": "описание груза",
+ "hs_code": "код ТН ВЭД",
+ "dangerous_goods": {{
+ "batteries": false,
+ "gases": false,
+ "liquids": false,
+ "dry_ice": false
+ }},
+ battery_mode — "В составе" | "Отдельно" | null,
+ "stackable_with_others": true,
+ "stackable_among_themselves": true,
+ "msds_required": false,
+ "batteries_packed_separately": false,
+ "dgm_report_required": false,
+ "brand_name": "название бренда",
+ "brand_authorization_letter": true,
+ "document_replacement_needed": null,
+ "transshipment_with_third_country": false,
+ "exporter_has_export_license": true,
+ "additional_services": ["переупаковка", "маркировка"],
+ "arrival_expediting_responsibility": "получатель",
+ "delivery_address": "адрес доставки",
+ "special_transport_requirements": "требования к транспорту (пропуск и т.п., не габариты ТС)",
+ "shipment_type": "FCL или LCL или \"\" — только при явном указании для морской/ж/д контейнерной перевозки",
+ "container_type": "количество и типоразмер ISO-контейнеров (как в критерии «Количество и типоразмер контейнеров»), если указано",
+ "vehicle_type": "тип автотранспорта для автодороги, если указано",
+ "temperature_range": "температурный режим, если указан",
+ "customs_clearance_required": true,
+ "customs_clearance_place_export_rf": "место оформления",
+ "fumigation_on_wooden_packaging": true,
+ "requested_shipping_type_names": ["ТОЧНЫЕ name из списка типов: все запрошенные/релевантные для этой партии; один элемент если способ один; все типы из списка если клиент просит любой/все варианты"],
+ "shipping_type": "одно имя из списка если requested_shipping_type_names длины 1, иначе \"\"",
+ "shipping_options": [
+ {{
+ "mode": "air|sea|road|rail",
+ "cost": "3500 USD",
+ "transit_time": "3-5 дней",
+ "details": "прямой рейс, с таможенным оформлением"
+ }}
+ ]
+ }}
+ ]
+}}
+ПРАВИЛА ЗАПОЛНЕНИЯ:
+Если информация отсутствует — используй null для чисел и для булевых полей (не ставь false, если факт неизвестен), пустую строку "" для текста, пустой массив [] для списков. Значение false для булевых — только если в письме явно указано отрицание.
+document_replacement_needed — только по явным фразам про замену документов; иначе null.
+Обязательно заполни requested_shipping_type_names по правилам блока «ВЫБОР ТИПОВ ПЕРЕВОЗКИ»; shipping_type — дублирование при одном типе, иначе "".
+В ID_emails укажи список ID писем, из которых взята информация для этой перевозки
+cargo_ready_date — дата готовности груза (ready date, ETD от производителя, «готов к отгрузке с …»): строка или массив строк; не выдумывай. Несколько дат для разных складов/партий — все в массиве или через запятую в одной строке.
+additional_services:
+- не перефразируй смысл.
+- не придумывай исполнителя услуги.
+- если в письме указано только кто оплачивает услугу — сохрани именно это.
+- не нормализуй текст additional_services.
+- если услуга есть в письме и из него же понятно что она нужна! то добавляй её в список.
+- сюда попадают любые требования которые встречаются в письме и не подходят для других категорий.
+- если упоминаются какие-то действия с документами (не считая замены документов) то они тоже относяться к дополнительному сервису.
+dimensions — массив объектов физических грузовых мест.
+battery_mode:
+- "В составе" → батарейки встроены или упакованы с оборудованием
+- "Отдельно" → батарейки перевозятся отдельно
+КРИТИЧЕСКИ ВАЖНО:
+- Каждое физическое грузовое место должно быть отдельным объектом в массиве dimensions.
+- НЕЛЬЗЯ объединять, агрегировать или схлопывать одинаковые коробки, паллеты или грузовые места.
+- Даже если размеры, вес и объём совпадают — всё равно создавай отдельный объект для каждого места.
+- Количество объектов dimensions должно соответствовать количеству физических мест в packing list.
+- Если указано 35 cartons одинакового размера — массив dimensions должен содержать 35 отдельных объектов.
+- Если в packing list есть несколько строк с одинаковыми размерами, но разным весом — каждая строка должна стать отдельным объектом dimensions.
+- Никогда не сокращай repeated rows из packing list.
+
+Поля:
+- length_cm
+- width_cm
+- height_cm
+
+всегда указываются в сантиметрах.
+
+Если в письме явно указаны мм (или числа вида 1200×800×600 без «см»), переведи в см или укажи:
+dimension_unit: "cm" | "mm" | "m"
+
+Указывай длинну, ширину и высоту в обратном json сразу переведёнными в сантиметры.
+
+Если габариты указаны в packing list/вложении (carton size / pallet size / dimensions), они ОБЯЗАТЕЛЬНО должны быть перенесены в dimensions.
+
+Если в письме или packing list указаны:
+- вес конкретного места
+- вес палеты
+- pallet weight
+- carton weight
+- net/gross weight per package
+- CBM per package
+- volume per pallet
+
+то необходимо сохранять их внутри соответствующего объекта dimensions как:
+
+- weight_kg
+- volume_cbm
+
+Пример:
+Если packing list содержит:
+35 cartons / 60×40×40 cm
+
+то dimensions должен содержать 35 отдельных объектов.
+vehicle_dimensions — только габариты транспортного средства (авто), не груза; [] если не указаны
+shipment_type — "FCL"/"LCL" или "" по правилам выше (не путать с типом перевозки shipping_type)
+shipping_options — только варианты, явно упомянутые или описанные в письмах/вложениях (цены, сроки — только если указаны). Если в тексте нет вариантов доставки — [] (пустой массив). Не придумывай стоимость, сроки и маршруты.
+container_type - строго вида 9х40 HC и тд
+weight_kg - если палет или коробок не более 5, и указан только общий вес, то попытайся разделить его поровню между грузами
+total_weight_kg - если груз в контейнере, то возвращай вес соответсвующий только одному контейнеру; не нужно складывать веса, если в письме уже указан общий вес для перевозки
+transshipment_with_third_country - если в письме говориться о разрыве накладных, то это то же самое, что если бы просто сказали "перелёт через третью страну нужен" и наоборот соответственно
+Контекст из писем для анализа:
+{context_text}
+Верни ТОЛЬКО валидный JSON-объект, начинающийся с {{ и заканчивающийся }}. Никакого дополнительного текста.
+
+"""
+
+ try:
+ response = self.openai_client.chat.completions.create(
+ model="deepseek-v4-flash",
+ # model="anthropic/claude-3.5-sonnet",
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": query_text}
+ ],
+ temperature=0.1,
+ reasoning_effort="low",
+ # max_tokens=10000,
+ # extra_body={
+ # "thinking": False
+ # }
+ )
+ answer_text = response.choices[0].message.content
+ finish_reason = response.choices[0].finish_reason
+
+ logger.info(f"Finish reason: {finish_reason}")
+
+ try:
+ logger.info(f"Usage: {response.usage}")
+ except Exception:
+ pass
+ logger.info(f"Model response received, length: {len(answer_text) if answer_text else 0}")
+ except Exception as e:
+ logger.error(f"Error calling OpenAI API: {e}", exc_info=True)
+ raise
+
+ structured_data = self._parse_json_response(answer_text)
+ documents_found = self.detect_documents(sources)
+ for shipment in structured_data.get("shipments", []):
+ shipment["documents_found"] = documents_found
+ # Если MSDS-документ реально найден во вложениях, усиливаем три-state:
+ # не переопределяем явное "msds_required = False", но если это None/пусто — ставим True.
+ if isinstance(documents_found, dict):
+ msds_docs = documents_found.get("msds") or []
+ if msds_docs and shipment.get("msds_required") is not False:
+ shipment["msds_required"] = True
+ structured_data = self._ensure_valid_structure(structured_data)
+ structured_data["shipments"] = self._merge_shipments_same_destination(
+ structured_data.get("shipments", []), context_text
+ )
+ structured_data["shipments"] = self._collapse_shipments_single_source(
+ structured_data.get("shipments", []), sources
+ )
+ shipments = structured_data.get("shipments", [])
+ for s in shipments:
+ if not isinstance(s, dict):
+ continue
+ if not (isinstance(s.get("container_type"), str) and s.get("container_type").strip()):
+ inferred_container = self._infer_container_type_from_sources(s, sources)
+ if inferred_container:
+ s["container_type"] = inferred_container
+
+ for s in shipments:
+ if not isinstance(s, dict):
+ continue
+ self._infer_brand_and_authorization_from_sources(s, sources)
+ self._infer_document_replacement_from_sources(s, sources)
+ self._infer_dangerous_goods_from_sources(s, sources)
+ self._enrich_special_requirements(s, sources)
+
+ # Детерминированно усиливаем числовые поля и габариты по тексту писем/вложений,
+ # чтобы не брать "первое попавшееся" и чтобы размеры извлекались по всем вариантам.
+ total_shipments = len(shipments) if isinstance(shipments, list) else 0
+ self._postprocess_total_shipments = total_shipments
+ for s in shipments:
+ self._postprocess_quantities_and_dimensions(s, sources)
+ self._postprocess_total_shipments = None
+
+ for s in shipments:
+ self.normalize_shipment(s, shipments)
+ self._enrich_operator_document_services(s, sources)
+ structured_data = self._enrich_with_shipping_types(structured_data, context_text, sources)
+
+ if preflight:
+ structured_data["preflight_classification"] = preflight
+
+ out = {
+ "answer": answer_text,
+ "structured_data": structured_data,
+ "sources": sources,
+ "total_emails_analyzed": len(sources)
+ }
+ self._save_cached_report(session_id, query_text, out)
+ self._maybe_auto_record_learning(session_id, context_text, out)
+ return self._normalize_api_report_payload(out)
+
+ def _parse_json_response(self, answer_text: str) -> Dict:
+ try:
+ text = answer_text.strip() if answer_text else ""
+ if not text:
+ raise ValueError("Empty response from model")
+
+ if "```" in text:
+ pattern = r'```(?:json)?\s*(.*?)```'
+ matches = re.findall(pattern, text, re.DOTALL)
+ if matches:
+ text = matches[0].strip()
+ else:
+ text = text.replace("```json", "").replace("```", "").strip()
+
+ start_idx = -1
+ for i, char in enumerate(text):
+ if char in '{[':
+ start_idx = i
+ break
+
+ if start_idx == -1:
+ raise ValueError("No JSON object or array found in response")
+
+ balance = 0
+ in_string = False
+ escape = False
+ opener = text[start_idx]
+ closer = '}' if opener == '{' else ']'
+ end_idx = start_idx
+
+ for i in range(start_idx, len(text)):
+ char = text[i]
+
+ if escape:
+ escape = False
+ continue
+ if char == '\\' and in_string:
+ escape = True
+ continue
+
+ if char == '"' and not escape:
+ in_string = not in_string
+ continue
+
+ if in_string:
+ continue
+
+ if char == opener:
+ balance += 1
+ elif char == closer:
+ balance -= 1
+ if balance == 0:
+ end_idx = i + 1
+ break
+
+ json_str = text[start_idx:end_idx].strip()
+
+ if not json_str:
+ raise ValueError("Extracted JSON string is empty")
+
+ result = json.loads(json_str)
+ result = _normalize_dict_keys(result)
+ if isinstance(result, list):
+ result = {"shipments": result}
+
+ return result
+
+ except json.JSONDecodeError as e:
+ logger.warning(f"JSON decode error: {e}")
+ logger.debug(f"Failed JSON preview: {answer_text[:500] if answer_text else 'None'}...")
+ return {"shipments": [], "parse_error": str(e), "raw_preview": answer_text[:300] if answer_text else ""}
+ except Exception as e:
+ logger.warning(f"Failed to parse JSON: {e}", exc_info=True)
+ return {"shipments": [], "parse_error": str(e), "raw_preview": answer_text[:300] if answer_text else ""}
+
+ def _ensure_valid_structure(self, data: Any) -> Dict:
+ if isinstance(data, str):
+ logger.error(f"Expected dict but got string: {data[:100]}")
+ return {"shipments": [], "error": "Invalid response format: string instead of object"}
+
+ if not isinstance(data, dict):
+ logger.warning(f"Expected dict but got {type(data)}, creating empty structure")
+ return {"shipments": []}
+
+ if "shipments" not in data:
+ data["shipments"] = []
+
+ sh_raw = data.get("shipments")
+ if isinstance(sh_raw, dict):
+ vals = list(sh_raw.values())
+ if vals and all(isinstance(x, dict) for x in vals):
+ data["shipments"] = vals
+ else:
+ data["shipments"] = []
+ elif not isinstance(sh_raw, list):
+ data["shipments"] = []
+
+ data["shipments"] = [s for s in data["shipments"] if isinstance(s, dict)]
+
+ for shipment in data["shipments"]:
+ if "ID_emails" not in shipment:
+ shipment["ID_emails"] = []
+ if "shipping_options" not in shipment:
+ shipment["shipping_options"] = []
+ if "dangerous_goods" not in shipment:
+ shipment["dangerous_goods"] = {}
+ if "dimensions" not in shipment:
+ shipment["dimensions"] = []
+ if "vehicle_dimensions" not in shipment:
+ shipment["vehicle_dimensions"] = []
+ if "shipping_type_candidates" not in shipment:
+ shipment["shipping_type_candidates"] = []
+ if "requested_shipping_type_names" not in shipment:
+ shipment["requested_shipping_type_names"] = []
+ crd = shipment.get("cargo_ready_date")
+ if isinstance(crd, list):
+ shipment["cargo_ready_date"] = ", ".join(
+ str(x).strip() for x in crd if x is not None and str(x).strip()
+ )
+ elif crd is not None and not isinstance(crd, str):
+ s = str(crd).strip()
+ shipment["cargo_ready_date"] = s if s else ""
+
+ return data
+
+ def _normalize_shipping_type_name_key(self, name: str) -> str:
+ """Мягкая нормализация имени типа перевозки для устойчивого сопоставления."""
+ if not isinstance(name, str):
+ return ""
+ n = name.strip().lower()
+ if not n:
+ return ""
+ # Приводим похожие символы и удаляем пунктуацию/пробелы.
+ n = n.replace("ё", "е")
+ n = n.replace(" + ", "+")
+ n = re.sub(r"[\"'`]", "", n)
+ n = re.sub(r"[\s\-\u2013\u2014_/.,;:()]+", "", n)
+ return n
+
+ def _shipping_type_record_by_name(self, name: str) -> Optional[Dict]:
+ n = (name or "").strip()
+ if not n:
+ return None
+ for st in self.shipping_types:
+ if isinstance(st, dict) and st.get("name") == n:
+ return st
+ key = self._normalize_shipping_type_name_key(n)
+ if not key:
+ return None
+ for st in self.shipping_types:
+ if not isinstance(st, dict):
+ continue
+ st_name = st.get("name")
+ if not isinstance(st_name, str):
+ continue
+ if self._normalize_shipping_type_name_key(st_name) == key:
+ return st
+ return None
+
+ def _normalize_requested_shipping_type_names(self, raw: Any) -> List[str]:
+ """Имена типов из ответа LLM: только известные из конфига, порядок сохраняется, без дублей."""
+ if raw is None:
+ return []
+ items: List[Any]
+ if isinstance(raw, str):
+ items = [raw]
+ elif isinstance(raw, list):
+ items = raw
+ else:
+ return []
+ known = {
+ st.get("name")
+ for st in self.shipping_types
+ if isinstance(st, dict) and isinstance(st.get("name"), str) and st.get("name")
+ }
+ known_by_key: Dict[str, str] = {}
+ for st_name in known:
+ k = self._normalize_shipping_type_name_key(st_name)
+ if k and k not in known_by_key:
+ known_by_key[k] = st_name
+ out: List[str] = []
+ seen: set[str] = set()
+ for x in items:
+ if not isinstance(x, str):
+ continue
+ n = x.strip()
+ if not n:
+ continue
+ resolved = n
+ if resolved not in known:
+ nk = self._normalize_shipping_type_name_key(resolved)
+ mapped = known_by_key.get(nk) if nk else None
+ if mapped:
+ resolved = mapped
+ if resolved not in known:
+ if n:
+ logger.warning("requested_shipping_type_names: неизвестное имя %r — пропуск", n)
+ continue
+ if resolved not in seen:
+ seen.add(resolved)
+ out.append(resolved)
+ return out
+
+ def _build_candidates_from_type_names(self, names: List[str]) -> List[Dict]:
+ out: List[Dict] = []
+ for n in names:
+ rec = self._shipping_type_record_by_name(n)
+ if not rec:
+ continue
+ cai = rec.get("criteria_ai") or rec.get("criteria") or ""
+ out.append(
+ {
+ "shipping_type": rec.get("name") or n,
+ "match_score": 1,
+ "criteria": str(cai),
+ }
+ )
+ return out
+
+ def _enrich_with_shipping_types(
+ self, structured_data: Dict, context_text: str = "", sources: Optional[List[Dict]] = None
+ ) -> Dict:
+ if not self.shipping_types:
+ logger.warning("No shipping types loaded, skipping enrichment")
+ return structured_data
+
+ shipments = structured_data.get('shipments', [])
+
+ skip_keys_for_match = frozenset({
+ "shipping_type",
+ "shipping_type_candidates",
+ "requested_shipping_type_names",
+ "criteria",
+ "criteria_preview",
+ "documents_found",
+ })
+
+ for shipment in shipments:
+ text_parts: List[str] = []
+
+ for key, value in shipment.items():
+ if key in skip_keys_for_match:
+ continue
+ if isinstance(value, str) and value:
+ text_parts.append(value.lower())
+ elif isinstance(value, dict):
+ for v in value.values():
+ if isinstance(v, str):
+ text_parts.append(v.lower())
+ elif isinstance(value, list):
+ for item in value:
+ if isinstance(item, str):
+ text_parts.append(item.lower())
+
+ if context_text:
+ text_parts.append(context_text.lower())
+
+ combined_text = " ".join(text_parts)
+
+ self._correct_multimodal_sea_rail_for_shipment(shipment, combined_text)
+
+ requested_names = self._normalize_requested_shipping_type_names(
+ shipment.get("requested_shipping_type_names")
+ )
+ if requested_names:
+ candidates = self._build_candidates_from_type_names(requested_names)
+ if candidates:
+ shipment["shipping_type_candidates"] = candidates
+ shipment["requested_shipping_type_names"] = [
+ c["shipping_type"] for c in candidates
+ ]
+ shipment["shipping_type"] = (
+ requested_names[0] if len(requested_names) == 1 else ""
+ )
+ shipment["criteria"] = (
+ candidates[0]["criteria"] if len(candidates) == 1 else ""
+ )
+ shipment["criteria_preview"] = ""
+ logger.info(
+ "shipping_type_candidates из LLM (requested_shipping_type_names): %s",
+ shipment["requested_shipping_type_names"],
+ )
+ self._reconcile_shipment_load_fields(shipment, combined_text)
+ continue
+
+ if self._text_requests_all_shipping_types(combined_text):
+ shipping_type_candidates: List[Dict] = []
+ for st in self.shipping_types:
+ if not isinstance(st, dict):
+ continue
+ name = st.get("name")
+ if not name:
+ continue
+ criteria_ai = st.get("criteria_ai") or st.get("criteria") or ""
+ shipping_type_candidates.append({
+ "shipping_type": name,
+ "match_score": 1,
+ "criteria": str(criteria_ai),
+ })
+ shipment["shipping_type_candidates"] = shipping_type_candidates
+ shipment["shipping_type"] = ""
+ shipment["criteria"] = ""
+ shipment["criteria_preview"] = ""
+ logger.info(
+ "Shipping types: letter requests all delivery modes → %d candidates (all configured types)",
+ len(shipping_type_candidates),
+ )
+ continue
+
+ # Тип перевозки задаёт LLM; здесь только подстановка criteria и согласование FCL/LCL.
+ llm_name = (shipment.get("shipping_type") or "").strip()
+ record = self._shipping_type_record_by_name(llm_name)
+
+ if record:
+ st_name = record.get("name") or ""
+ criteria_ai = record.get("criteria_ai") or record.get("criteria") or ""
+ cstr = str(criteria_ai)
+ shipment["shipping_type"] = st_name
+ shipment["criteria"] = cstr
+ shipment["criteria_preview"] = ""
+ shipment["shipping_type_candidates"] = [
+ {"shipping_type": st_name, "match_score": 0, "criteria": cstr}
+ ]
+ self._reconcile_shipment_load_fields(shipment, combined_text)
+ continue
+
+ if llm_name:
+ shipment["criteria"] = ""
+ shipment["criteria_preview"] = ""
+ shipment["shipping_type_candidates"] = [
+ {"shipping_type": llm_name, "match_score": 0, "criteria": ""}
+ ]
+ self._reconcile_shipment_load_fields(shipment, combined_text)
+ logger.warning(
+ "shipping_type %r не найден в конфигурации типов; criteria оставлено пустым",
+ llm_name,
+ )
+ continue
+
+ recipient_emails = self._recipients_for_shipment(shipment, sources or [])
+ recipient_st = self._pick_recipient_shipping_type(
+ recipient_emails, combined_text
+ )
+ if recipient_st:
+ criteria_ai = recipient_st.get("criteria_ai") or recipient_st.get("criteria") or ""
+ cstr = str(criteria_ai)
+ rname = recipient_st.get("name") or ""
+ shipment["shipping_type_candidates"] = [
+ {"shipping_type": rname, "match_score": 0, "criteria": cstr}
+ ]
+ shipment["shipping_type"] = rname
+ shipment["criteria"] = cstr
+ shipment["criteria_preview"] = ""
+ logger.info(
+ "Shipping type по ящику получателя (LLM не указал тип): name=%s recipients=%s",
+ rname,
+ sorted(recipient_emails),
+ )
+ else:
+ shipment["shipping_type_candidates"] = []
+ shipment["shipping_type"] = ""
+ shipment["criteria"] = ""
+ shipment["criteria_preview"] = ""
+ logger.info("Shipping type: LLM пусто и нет сопоставления по получателям писем")
+
+ self._reconcile_shipment_load_fields(shipment, combined_text)
+
+ return structured_data
+
+ def _reconcile_shipment_load_fields(self, shipment: Dict, combined_text: str) -> None:
+ """Согласует shipment_type (FCL/LCL) с выбранным shipping_type и текстом."""
+ if not isinstance(shipment, dict):
+ return
+ name = shipment.get("shipping_type") or ""
+ implied = _shipping_type_name_implies_load_mode(name)
+ if not implied:
+ return
+ t_lm = infer_container_load_mode_from_text((combined_text or "").lower())
+ cur_raw = shipment.get("shipment_type")
+ cur = (cur_raw or "").strip().upper() if isinstance(cur_raw, str) else ""
+ if cur not in ("LCL", "FCL"):
+ shipment["shipment_type"] = implied
+ return
+ if cur != implied:
+ if t_lm == cur:
+ return
+ if t_lm == implied:
+ shipment["shipment_type"] = implied
+ elif t_lm is None:
+ shipment["shipment_type"] = implied
+
+ def _save_report_to_file(self, session_id: str, data: Dict):
+ try:
+ reports_dir = "/opt/nek/app/reports"
+ os.makedirs(reports_dir, exist_ok=True)
+
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+ filename = os.path.join(reports_dir, f"{session_id}_{timestamp}.json")
+
+ with open(filename, 'w', encoding='utf-8') as f:
+ json.dump(data, f, ensure_ascii=False, indent=2, default=str)
+
+ logger.info(f"Report saved to {filename}")
+
+ except Exception as e:
+ logger.error(f"Failed to save report: {e}", exc_info=True)
+
+ def _prepare_template_data(self, shipment: Dict, missing_fields: List[str]) -> Dict:
+ def _first_non_empty_str(*candidates: Any, default: str = "не указан") -> str:
+ for c in candidates:
+ if c is None:
+ continue
+ s = str(c).strip()
+ if s:
+ return s
+ return default
+
+ _ct_src = shipment.get("container_type")
+ _ct_raw = str(_ct_src).strip() if isinstance(_ct_src, str) and _ct_src.strip() else ""
+
+ _crd = shipment.get("cargo_ready_date")
+ if isinstance(_crd, list):
+ _crd_str = ", ".join(str(x).strip() for x in _crd if x is not None and str(x).strip())
+ else:
+ _crd_str = str(_crd).strip() if _crd is not None else ""
+
+ data = {
+ "client_name": str(shipment.get("client_name") or "Клиент").strip() or "Клиент",
+ "cargo_ready_date": _crd_str or "не указана",
+ "pickup_address": str(shipment.get("pickup_address") or "не указан").strip() or "не указан",
+ "delivery_address": str(shipment.get("delivery_address") or "не указан").strip() or "не указан",
+ "total_weight_kg": shipment.get("total_weight_kg") if shipment.get(
+ "total_weight_kg") is not None else "не указан",
+ "package_count": shipment.get("package_count") if shipment.get(
+ "package_count") is not None else "не указан",
+ "total_volume_cbm": shipment.get("total_volume_cbm") if shipment.get(
+ "total_volume_cbm") is not None else "не указан",
+ "cargo_description": str(shipment.get("cargo_description") or "не указано").strip() or "не указано",
+ "hs_code": str(shipment.get("hs_code") or "не указан").strip() or "не указан",
+ # Три-стейт для строгой логики: неизвестно -> "Информация отсутствует"
+ "msds_required": (
+ "✅ Да"
+ if shipment.get("msds_required") is True
+ else ("❌ Нет" if shipment.get("msds_required") is False else "Информация отсутствует")
+ ),
+ "missing_fields": "",
+ "estimated_cost": "по запросу",
+ "estimated_transit_time": "по запросу",
+ "dimensions_str": "не указаны",
+ "dangerous_goods_str": "Информация отсутствует",
+ "brand_authorization_info": "Информация отсутствует",
+ "loading_port": _first_non_empty_str(
+ shipment.get("loading_port"), shipment.get("pickup_address")
+ ),
+ "discharge_port": _first_non_empty_str(
+ shipment.get("discharge_port"), shipment.get("delivery_address")
+ ),
+ "shipment_type": _first_non_empty_str(
+ shipment.get("shipment_type"),
+ default="не указан",
+ ),
+ "container_type": normalize_container_type_display(
+ _ct_raw or None,
+ empty="не указан",
+ ),
+ "vehicle_type": _first_non_empty_str(shipment.get("vehicle_type"), default="не указан"),
+ "temperature_range": _first_non_empty_str(
+ shipment.get("temperature_range"), default="не указан"
+ ),
+ "vehicle_dimensions_str": "не указаны",
+ }
+
+ # Габариты
+ dims = shipment.get("dimensions") or []
+
+ formatted_dimensions = []
+
+ for idx, dim in enumerate(dims, start=1):
+
+ if not isinstance(dim, dict):
+ continue
+
+ length = dim.get("length_cm")
+ width = dim.get("width_cm")
+ height = dim.get("height_cm")
+
+ weight = dim.get("weight_kg")
+ volume = dim.get("volume_cbm")
+
+ size_parts = []
+
+ # размеры
+ dims_parts = []
+
+ if length not in (None, "", 0):
+ dims_parts.append(str(length))
+
+ if width not in (None, "", 0):
+ dims_parts.append(str(width))
+
+ if height not in (None, "", 0):
+ dims_parts.append(str(height))
+
+ if dims_parts:
+ size_parts.append(
+ f"{'x'.join(dims_parts)} см"
+ )
+
+ # вес места
+ if weight not in (None, "", 0):
+ size_parts.append(f"{weight} кг")
+
+ # объём места
+ if volume not in (None, "", 0):
+ size_parts.append(f"{volume} м³")
+
+ if size_parts:
+ formatted_dimensions.append(
+ f"{idx}) " + " / ".join(size_parts)
+ )
+
+ if formatted_dimensions:
+ data["dimensions_str"] = "\n".join(formatted_dimensions)
+ else:
+ data["dimensions_str"] = "не указаны"
+
+ vdims = shipment.get("vehicle_dimensions", [])
+ if isinstance(vdims, list) and vdims:
+ vdim_strings = []
+ for d in vdims:
+ if isinstance(d, dict):
+ l = d.get("length_cm")
+ w = d.get("width_cm")
+ h = d.get("height_cm")
+ if l and w and h:
+ vdim_strings.append(f"{l}×{w}×{h} см")
+ if vdim_strings:
+ data["vehicle_dimensions_str"] = "; ".join(vdim_strings)
+
+ # Опасные грузы
+ dg_labels = {
+ "batteries": "батарейки",
+ "gases": "газы",
+ "liquids": "жидкости",
+ "dry_ice": "сухой лёд",
+ }
+ dg_keys = ("batteries", "gases", "liquids", "dry_ice")
+ dg = shipment.get("dangerous_goods", {})
+ if isinstance(dg, dict):
+ yes = [dg_labels[k] for k in dg_keys if dg.get(k) is True]
+ explicit_no = [dg_labels[k] for k in dg_keys if dg.get(k) is False]
+ if yes:
+ data["dangerous_goods_str"] = "Да: " + ", ".join(yes)
+ elif len(explicit_no) == len(dg_keys):
+ data["dangerous_goods_str"] = "Нет"
+ elif explicit_no:
+ data["dangerous_goods_str"] = (
+ "Нет: " + ", ".join(explicit_no) + " — по остальным категориям информации нет"
+ )
+ else:
+ note = shipment.get("dangerous_goods_note")
+ if isinstance(note, str) and note.strip():
+ data["dangerous_goods_str"] = note.strip()
+ else:
+ data["dangerous_goods_str"] = "Информация отсутствует"
+
+ # Авторизационное письмо бренда — выводим максимально полный контекст.
+ brand_info = shipment.get("brand_authorization_info")
+ if isinstance(brand_info, str) and brand_info.strip():
+ data["brand_authorization_info"] = brand_info.strip()
+ data["brand_authorization_letter"] = brand_info.strip()
+ else:
+ brand_flag = shipment.get("brand_authorization_letter")
+ if brand_flag is True:
+ fallback = "Требуется авторизационное письмо бренда, подробности в письме не найдены."
+ elif brand_flag is False:
+ fallback = "Авторизационное письмо бренда не требуется (по переписке)."
+ else:
+ fallback = "Информация отсутствует"
+ data["brand_authorization_info"] = fallback
+ data["brand_authorization_letter"] = fallback
+
+ # Стоимость и срок
+ shipping_options = shipment.get("shipping_options", [])
+ if isinstance(shipping_options, list) and shipping_options:
+ first_opt = shipping_options[0]
+ if isinstance(first_opt, dict):
+ cost = first_opt.get("cost")
+ transit = first_opt.get("transit_time")
+ if cost:
+ data["estimated_cost"] = str(cost).strip() or "по запросу"
+ if transit:
+ data["estimated_transit_time"] = str(transit).strip() or "по запросу"
+
+ # Формирование списка недостающих полей
+ if missing_fields:
+ missing_labels = [FIELD_LABELS.get(f, f) for f in missing_fields]
+ if len(missing_labels) > 1:
+ data["missing_fields"] = "\n".join(f"- {item}" for item in missing_labels)
+ else:
+ data["missing_fields"] = missing_labels[0] if missing_labels else ""
+
+ return data
+
+ def detect_documents(self, sources):
+ """
+ Находит документы в прикреплённых файлах.
+ Важное правило: один файл не должен одновременно попадать в MSDS и DGM
+ (на практике это почти всегда отдельные файлы).
+ """
+ result = {
+ "msds": [],
+ "dgm": [],
+ "brand_authorization": []
+ }
+
+ keywords = {
+ "msds": [
+ "msds", "material safety data sheet", "sds", "safety data sheet"
+ ],
+ "dgm": [
+ "dangerous goods declaration", "dgm", "dgd", "shipper's declaration",
+ "imo declaration", "imdg declaration"
+ ],
+ "brand_authorization": ["authorization letter", "authorisation letter", "brand authorization"]
+ }
+
+ def score_doc(doc_type: str, filename_l: str, text_l: str) -> int:
+ words = keywords.get(doc_type, [])
+ score = 0
+ if any(w in filename_l for w in words):
+ score += 3
+ if any(w in text_l for w in words):
+ score += 1
+ return score
+
+ def classify_attachment(filename_l: str, text_l: str) -> Optional[str]:
+ scores = {k: score_doc(k, filename_l, text_l) for k in keywords.keys()}
+ if all(v == 0 for v in scores.values()):
+ return None
+
+ # Спец-логика MSDS vs DGM: не разрешаем одной вложке быть сразу обоими
+ msds_s = scores.get("msds", 0)
+ dgm_s = scores.get("dgm", 0)
+ if msds_s > 0 and dgm_s > 0:
+ # приоритет по названию файла
+ if any(w in filename_l for w in keywords["msds"]) and not any(w in filename_l for w in keywords["dgm"]):
+ scores["dgm"] = 0
+ elif any(w in filename_l for w in keywords["dgm"]) and not any(
+ w in filename_l for w in keywords["msds"]):
+ scores["msds"] = 0
+ else:
+ # иначе выбираем что более уверенно, при равенстве — считаем DGM более специфичным
+ if dgm_s >= msds_s:
+ scores["msds"] = 0
+ else:
+ scores["dgm"] = 0
+
+ best_doc = max(scores.items(), key=lambda kv: kv[1])[0]
+ return best_doc if scores[best_doc] > 0 else None
+
+ seen: set = set() # (email_id, filename, doc_type)
+
+ for src in sources or []:
+ for att in src.get("attachments", []) or []:
+ filename = att.get("filename") or ""
+ filename_l = filename.lower()
+ text_l = (att.get("text") or "").lower()
+
+ doc_type = classify_attachment(filename_l, text_l)
+ if not doc_type:
+ continue
+
+ key = (src.get("id"), filename, doc_type)
+ if key in seen:
+ continue
+ seen.add(key)
+
+ result[doc_type].append({
+ "filename": filename,
+ "email_subject": src.get("subject"),
+ "email_id": src.get("id")
+ })
+
+ return result
+
+
+
+ def _generate_response_letter(self, structured_data: Any) -> str:
+ """
+ Генерирует письмо для клиента (для отображения в отчете).
+ В отчете всегда формируется client letter по info_request_template.
+ """
+
+ def _clean_text(text: str) -> str:
+ if not text:
+ return ""
+ lines = text.splitlines()
+ result = []
+ prev_empty = False
+ for line in lines:
+ stripped = line.strip()
+ if stripped == "":
+ if not prev_empty:
+ result.append("")
+ prev_empty = True
+ else:
+ result.append(line.rstrip())
+ prev_empty = False
+ return "\n".join(result).strip()
+
+
+ def _extract_greeting_and_signature(letter_text: str) -> tuple[str, str, str]:
+ text = _clean_text(letter_text or "")
+ if not text:
+ return "", "", ""
+
+ lines = text.splitlines()
+
+ greeting_lines: List[str] = []
+ i = 0
+ while i < len(lines) and lines[i].strip() != "":
+ greeting_lines.append(lines[i].rstrip())
+ i += 1
+ greeting = "\n".join(greeting_lines).strip()
+
+ sig_idx = None
+ for idx in range(len(lines) - 1, -1, -1):
+ low = lines[idx].strip().lower()
+ if low.startswith("с уважением") or low.startswith("best regards"):
+ sig_idx = idx
+ break
+ if sig_idx is not None:
+ signature = "\n".join(lines[sig_idx:]).strip()
+ body_lines = lines[i:sig_idx]
+ else:
+ signature = ""
+ body_lines = lines[i:]
+
+ body = _clean_text("\n".join(body_lines))
+ return greeting, body, signature
+
+ if not isinstance(structured_data, dict):
+ logger.error(f"Expected dict for structured_data, got {type(structured_data)}")
+ return "Ошибка: не удалось получить данные для формирования письма."
+
+ shipments = structured_data.get('shipments', [])
+
+ if not isinstance(shipments, list) or not shipments:
+ return "Не удалось извлечь данные о грузоперевозках из писем."
+
+ shipping_types = load_shipping_types()
+ if not shipping_types:
+ return "Ошибка: шаблоны писем не найдены в конфигурации."
+
+ sections: List[str] = []
+ greeting = ""
+ signature = ""
+
+ valid_shipments = [s for s in shipments if isinstance(s, dict)]
+ if not valid_shipments:
+ return "Не удалось извлечь данные о грузоперевозках из писем."
+
+ def _candidates_for_letter(sh: Dict) -> List[Dict]:
+ cands = sh.get("shipping_type_candidates")
+ if isinstance(cands, list) and cands:
+ return [c for c in cands if isinstance(c, dict)]
+ st_name = sh.get("shipping_type") or ""
+ return [{"shipping_type": st_name, "criteria": sh.get("criteria", "")}]
+
+ default_type = shipping_types[0]
+
+ section_idx = 0
+ for i, shipment in enumerate(valid_shipments, start=1):
+ required_fields = [
+ "client_name", "incoterms", "pickup_address", "cargo_value",
+ "package_count", "total_weight_kg", "dimensions", "total_volume_cbm",
+ "cargo_description", "delivery_address"
+ ]
+
+ missing: List[str] = []
+ for field in required_fields:
+ val = shipment.get(field)
+ if field == "dimensions":
+ if not val or not isinstance(val, list) or not val:
+ missing.append(field)
+ elif (
+ val is None
+ or (isinstance(val, (int, float)) and float(val) == 0)
+ or (isinstance(val, str) and val.strip() in {"", "0", "0.0", "0,0", "0,00"})
+ ):
+ missing.append(field)
+
+ # Под отчетами показываем только клиентское письмо.
+ # Письмо контрагенту (confirmation_template) не включаем в report output.
+ template_key = "info_request_template"
+
+ for variant in _candidates_for_letter(shipment):
+ type_name = (variant.get("shipping_type") or "").strip() or "Тип не указан"
+ selected_type = None
+ if type_name and type_name != "Тип не указан":
+ selected_type = next(
+ (st for st in shipping_types if st.get("name") == type_name),
+ None,
+ )
+ if not selected_type:
+ selected_type = default_type
+
+ template = selected_type.get(template_key, "")
+ if not template:
+ continue
+
+ variant_missing = list(missing)
+ variant_missing.extend(
+ collect_extra_required_missing(shipment, selected_type)
+ )
+ variant_missing = list(dict.fromkeys(variant_missing))
+
+ template_data = self._prepare_template_data(shipment, variant_missing)
+
+ try:
+ letter_one = auto_fill_template(template, template_data, FIELD_LABELS)
+ except Exception as e:
+ logger.error(f"Template formatting error: {e}", exc_info=True)
+ letter_one = "Произошла ошибка при формировании текста письма."
+
+ g, body, sig = _extract_greeting_and_signature(letter_one)
+ if not greeting and g:
+ greeting = g
+ if not signature and sig:
+ signature = sig
+
+ section_body = body if body else _clean_text(letter_one)
+ section_idx += 1
+ sections.append(
+ f"Перевозка ({section_idx}) — {type_name}\n\n{section_body}".strip()
+ )
+
+ combined = "\n\n".join(sections).strip()
+ result_parts = [p for p in [greeting, combined, signature] if p]
+ return _clean_text(
+ "\n\n".join(result_parts)) if result_parts else "Не удалось сформировать письмо по перевозкам."
+
+ async def generate_cargo_report(self, session_id: str) -> Dict:
+ query = """
+ Составь полный структурированный отчёт о всех грузоперевозках из предоставленных писем.
+ Для каждой перевозки укажи все доступные детали: маршрут, характеристики груза,
+ особые условия, требуемые документы и предложи варианты доставки.
+ """
+
+ def _clean_emails_for_learning(emails):
+ cleaned = []
+ for e in emails:
+ e = dict(e)
+
+ # удаляем base64
+ for att in e.get("attachments", []):
+ att.pop("content_base64", None)
+
+ # режем тело письма
+ if isinstance(e.get("body"), str):
+ e["body"] = e["body"][:5000]
+
+ cleaned.append(e)
+ return cleaned
+
+ context_text, sources = self._collect_session_sources(session_id)
+ result = await self.query_cargo_info(query, session_id, top_k=50)
+ letter = self._generate_response_letter(result.get('structured_data', {}))
+ result['generated_letter'] = letter
+ result = self._normalize_api_report_payload(result)
+ result['context_text'] = context_text
+ result['emails'] = sources
+ if session_id:
+ self._save_report_to_file(session_id, result)
+ return result
diff --git a/shipping_calculator.py b/shipping_calculator.py
new file mode 100644
index 0000000..8969417
--- /dev/null
+++ b/shipping_calculator.py
@@ -0,0 +1,119 @@
+import math
+import re
+from typing import Dict, Optional
+
+def calculate_shipping_cost(shipment: Dict) -> Optional[Dict]:
+ """
+ Расчёт стоимости авиаперевозки на основе параметров груза.
+ Возвращает словарь с ключами:
+ total_usd, total_rub, chargeable_weight, actual_weight, volume_weight
+ или None, если не хватает данных.
+ """
+ try:
+ # Проверяем наличие обязательных данных
+ if not all(k in shipment for k in ['total_weight_kg', 'dimensions', 'cargo_value']):
+ return None
+
+ # Параметры груза
+ actual_weight = shipment.get('total_weight_kg', 0)
+ if not actual_weight or actual_weight <= 0:
+ return None
+
+ # Расчёт объёмного веса
+ dimensions = shipment.get('dimensions', [])
+ if not dimensions:
+ return None
+
+ total_volume_weight = 0
+ for dim in dimensions:
+ if dim.get('length_cm') and dim.get('width_cm') and dim.get('height_cm'):
+ # Объёмный вес = (Д×Ш×В в метрах) × 167
+ length_m = dim['length_cm'] / 100
+ width_m = dim['width_cm'] / 100
+ height_m = dim['height_cm'] / 100
+ volume = length_m * width_m * height_m
+ volume_weight = volume * 167
+ total_volume_weight += volume_weight
+
+ # Платный вес (оплачивается по большему)
+ chargeable_weight = max(actual_weight, total_volume_weight)
+ chargeable_weight = math.ceil(chargeable_weight) # округляем вверх
+
+ # Базовый тариф в зависимости от веса (USD/кг)
+ if chargeable_weight < 45:
+ base_rate = 6.5
+ elif chargeable_weight < 100:
+ base_rate = 5.5
+ elif chargeable_weight < 300:
+ base_rate = 4.5
+ elif chargeable_weight < 500:
+ base_rate = 3.8
+ elif chargeable_weight < 1000:
+ base_rate = 3.2
+ else:
+ base_rate = 2.8
+
+ # Топливный сбор и сбор за безопасность
+ fuel_surcharge_rate = 0.9 # USD/кг
+ min_fuel_surcharge = 90 # USD минимум
+
+ # Терминальные сборы
+ terminal_export_rate = 0.22 # USD/кг (~20 руб/кг)
+ terminal_export_fixed = 25 # USD фиксированных сборов (~2300 руб)
+ terminal_import_rate = 0.35 # EUR/кг ≈ 0.38 USD/кг по курсу 1.1
+
+ # Таможенное оформление
+ customs_export = 170 # USD (~15500 руб)
+ customs_import = 220 # EUR ≈ 242 USD
+
+ # Надбавки за особые условия
+ special_rate_multiplier = 1.0
+
+ # Терморежим (+15…+25 °C)
+ if shipment.get('special_transport_requirements') and 'термо' in str(shipment.get('special_transport_requirements', '')).lower():
+ special_rate_multiplier *= 1.3 # +30%
+
+ # Страхование
+ cargo_value_usd = 0
+ cargo_value_str = shipment.get('cargo_value', '0')
+ # Извлекаем числовое значение из строки (например, "18 500 EUR" -> 18500)
+ value_match = re.search(r'[\d\s]+', cargo_value_str.replace(' ', ''))
+ if value_match:
+ cargo_value_usd = float(value_match.group().replace(' ', ''))
+ # Если указано в EUR, конвертируем
+ if 'EUR' in cargo_value_str or '€' in cargo_value_str:
+ cargo_value_usd *= 1.1 # приблизительный курс EUR/USD
+
+ insurance_cost = cargo_value_usd * 0.01 # 1% от стоимости груза
+
+ # Расчёт компонентов
+ base_freight = chargeable_weight * base_rate * special_rate_multiplier
+ fuel_surcharge = max(chargeable_weight * fuel_surcharge_rate, min_fuel_surcharge)
+ terminal_export = chargeable_weight * terminal_export_rate + terminal_export_fixed
+ terminal_import = chargeable_weight * 0.38 # конвертация EUR->USD
+ customs_total = customs_export + customs_import
+
+ # Дополнительные услуги
+ additional_services = shipment.get('additional_services', [])
+ additional_cost = 0
+ if isinstance(additional_services, list):
+ if 'уведомление о прибытии' in str(additional_services).lower():
+ additional_cost += 25 # USD
+
+ # Итог
+ total_usd = (base_freight + fuel_surcharge + terminal_export +
+ terminal_import + customs_total + additional_cost + insurance_cost)
+
+ # Конвертация в рубли для удобства (курс USD/RUB ~90)
+ total_rub = total_usd * 90
+
+ return {
+ "total_usd": round(total_usd, 2),
+ "total_rub": round(total_rub, 2),
+ "chargeable_weight": chargeable_weight,
+ "actual_weight": actual_weight,
+ "volume_weight": round(total_volume_weight, 2)
+ }
+ except Exception as e:
+ # Логирование ошибки, если нужно
+ return None