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