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
from precheck.additional_services import extract_additional_services
try:
    from container_reference import iso_reference_prompt_block
except ImportError:
    def iso_reference_prompt_block() -> str:
        return ""
from email_cleaner import clean_email_context

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"(?<![a-zа-яё])(мм|mm)(?![a-zа-яё])", ll))
    has_cm = bool(re.search(r"(?<![a-zа-яё])(см|cm)(?![a-zа-яё])", ll))
    if has_mm and not has_cm:
        return "mm"
    if has_cm and not has_mm:
        return "cm"
    if has_mm and has_cm:
        # оба упомянуты — чаще в переписке дают см; не делим на 10 без других признаков
        return "cm"
    return None


def _max_llm_context_chars() -> 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]

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.])(?P<n>\d{1,3})\s*[x×х*＊]\s*"
    r"(?P<t>(?: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)

        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

    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)
        # Регулярка для поиска e-mail в любом контексте
        emails = re.findall(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", text)
        # Приводим к нижнему регистру и убираем дубликаты
        return list(dict.fromkeys(e.lower().strip() for e in emails))

    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_emails = st.get("employee_email") or ""
            # Разбиваем строку с несколькими адресами
            bound_emails = self._extract_emails_from_text(raw_emails)
            if not bound_emails:
                continue
            # Если есть пересечение – тип подходит
            if any(e in recipients for e in bound_emails):
                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)

                if "place_no" in d:
                    normalized["place_no"] = d["place_no"]
                if d.get("place_type"):
                    normalized["place_type"] = d["place_type"]
                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 and not (d.get("place_type") or d.get("description")):
                    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"(?<!\d)(\d{1,2})\s*(?:\*|x)?\s*(20|40|45)\s*(dc|hc|hq|gp|rf|rh|reefer|ref|реф|рефр|ot|fr)\b",
            re.IGNORECASE)
        for m in p_count.finditer(t):
            cnt = m.group(1)
            size = m.group(2)
            kind = m.group(3)
            results.append(f"{cnt}x{_normalize_type(size, kind)}")

        # Одиночные: 40HC, 20DC, 45OT
        p_single = re.compile(r"\b(20|40|45)\s*(dc|hc|hq|gp|rf|rh|reefer|ref|реф|рефр|ot|fr)\b", re.IGNORECASE)
        for m in p_single.finditer(t):
            size = m.group(1)
            kind = m.group(2)
            token = _normalize_type(size, kind)
            if token not in results:
                results.append(token)

        # Явные контейнеры без типа: "контейнер 40ф"
        p_foot = re.compile(r"\bконтейнер\w*\s*(20|40|45)\s*f?\b", re.IGNORECASE)
        for m in p_foot.finditer(t):
            token = m.group(1)
            if token not in results:
                results.append(token)

        return list(dict.fromkeys(results))

    def _infer_container_type_from_sources(self, shipment: Dict, sources: List[Dict]) -> 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<items>[^\.;]+)', 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)
            shipment["msds_required"] = False
            shipment["dgm_report_required"] = False

        # Если есть хотя бы одна конкретная категория опасного груза – требуем MSDS
        any_true = any(dg.get(k) is True for k in ("batteries", "gases", "liquids", "dry_ice"))
        if any_true:
            if shipment.get("msds_required") is not False:
                shipment["msds_required"] = True
            # (здесь можно при желании добавить и dgm_report_required)
            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:
            if shipment.get("msds_required") is not False:
                shipment["msds_required"] = True

            # Извлекаем конкретные упоминания опасных веществ из текста
            blob = (low_email or "") + "\n" + (low_att or "")
            dangerous_phrases = self._extract_dangerous_phrases(blob)
            if dangerous_phrases:
                shipment["dangerous_goods_note"] = "Опасный груз: " + "; ".join(dangerous_phrases)
            else:
                # Приоритет: cargo_description из письма
                cargo_desc = shipment.get("cargo_description")
                if isinstance(cargo_desc, str) and cargo_desc.strip():
                    desc_clean = cargo_desc.strip()
                    # Ограничиваем длину для читаемости
                    if len(desc_clean) > 200:
                        desc_clean = desc_clean[:200] + "..."
                    shipment["dangerous_goods_note"] = desc_clean
                else:
                    # Последний fallback — общая фраза
                    shipment["dangerous_goods_note"] = "Обнаружены признаки опасного груза (детали не распознаны)"

    def _extract_dangerous_phrases(self, text: str) -> List[str]:
        """
        Извлекает из текста короткие фразы, явно указывающие на опасные вещества/материалы.
        Возвращает список уникальных нормализованных строк.
        """
        if not text:
            return []

        # Ключевые слова и паттерны для поиска упоминаний
        patterns = [
            # Химия / жидкости
            r'(?:легковоспламеняющ\w*\s+жидкость|flammable\s+liquid)',
            r'(?:химикат\w*|хими\w*\s+(?:вещество|продукт|реагент)|chemical\w*)',
            r'(?:растворител\w*|solvent\w*)',
            r'(?:краск\w*|paint\w*)',
            r'(?:смол\w*|resin\w*)',
            r'(?:масл\w*|oil\w*)',
            # Аэрозоли / газы
            r'(?:аэрозол\w*|aerosol\w*)',
            r'(?:газ\w*\s+(?:баллон|сжиженн|пропан|бутан)|gas\s+cylinder|propane|butane)',
            # Батарейки / аккумуляторы
            r'(?:литий[-\s]*(?:ионны\w*|полимерн\w*)|lithium[-\s]*(?:ion|polymer))',
            r'(?:батаре\w*|аккумулятор\w*|batter\w*|cell\w*)',
            r'(?:power\s*bank)',
            # Классы опасности (если указаны)
            r'(?:class|класс)\s*[1-9]\b',
            r'(?:UN\s*\d{4}|ООН\s*\d{4})',
            # Сухой лёд
            r'(?:сухой\s+л[её]д|dry\s+ice)',
            # Порошки (иногда опасные)
            r'(?:порош\w*|powder\w*)',
            # Общее: опасное вещество / hazmat
            r'(?:опасн\w+\s+(?:вещество|груз|материал)|hazmat|dangerous\s+goods)',
        ]

        found = set()
        for pat in patterns:
            for match in re.finditer(pat, text, re.IGNORECASE):
                phrase = match.group(0).strip()
                # нормализуем пробелы и приводим к нижнему регистру для дедупликации
                clean = re.sub(r'\s+', ' ', phrase).lower()
                found.add(clean)

        # Возвращаем в читаемом виде, ограничивая количество
        return list(found)[:5]  # не более 5 фраз, чтобы не перегружать

    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<ctn>\d+(?:[.,]\d+)?)\s*(?:ctns?|carton(?:s)?|boxes?)\b"
            r".{0,80}?"
            r"(?P<kg>\d+(?:[.,]\d+)?)\s*(?:kgs?|kg|кг)\b"
            r".{0,80}?"
            r"(?P<cbm>\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)(?P<ctn>\d+(?:[.,]\d+)?)\s*(?:ctns?|carton(?:s)?|boxes?)\b",
            re.IGNORECASE,
        )
        kg_re = re.compile(
            r"(?<!\d)(?P<kg>\d+(?:[.,]\d+)?)\s*(?:kgs?|kg|кг)\b",
            re.IGNORECASE,
        )
        cbm_re = re.compile(
            r"(?<!\d)(?P<cbm>\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<num>\d+(?:[.,]\d+)?)\s*(?P<unit>kgs?|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<a>\d+(?:[.,]\d+)?)\s*(?:x|×|X)\s*(?P<b>\d+(?:[.,]\d+)?)\s*(?:x|×|X)\s*(?P<c>\d+(?:[.,]\d+)?)"
            r"\s*(?P<u>мм|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<a>\d+(?:[.,]\d+)?)\s*(?:x|×|X)\s*(?P<b>\d+(?:[.,]\d+)?)\s*(?:x|×|X)\s*(?P<c>\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:
        if isinstance(shipment.get("total_weight_kg"), str):
            return  # не трогаем строки

        """
        Детерминированное усиление:
        - пересчитать 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)

            if d.get("place_type"):
                norm["place_type"] = d["place_type"]
            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 _enrich_additional_services(self, shipment: Dict, sources: List[Dict]) -> None:
        """
        Детерминированно дополняет список additional_services для перевозки
        на основе текста писем и вложений.
        """
        raw_text = self._collect_shipment_source_text(shipment, sources)
        new_services = extract_additional_services(raw_text)
        if not new_services:
            return

        existing = shipment.get("additional_services")
        if not isinstance(existing, list):
            existing = []
        existing_set = {str(s).strip().lower() for s in existing if s}

        for s in new_services:
            key = s.strip().lower()
            if key not in existing_set:
                existing.append(s)
                existing_set.add(key)

        shipment["additional_services"] = existing

    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):

                metadata = email.get("metadata", {}) or {}
                email_id = metadata.get("email_id") or f"{session_id}_{idx}"

                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)

        context_text = clean_email_context(context_text)

        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:
        # внутри query_cargo_info
        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-формате, не добавляя домыслов.

КЛЮЧЕВЫЕ ПРИНЦИПЫ
    Отсутствующие данные: null для булевых, "" для строк, [] для массивов.
    Не додумывай факты. Если явных указаний нет — не заполняй!
    Приоритет источников: основной текст письма > вложения > подписи/дисклеймеры.
    Если в письме указываются какие-то критерии через или то вставляй оба варианта, не объединяя их!
    Несколько shipment’ов создавай только при явном разделении на независимые партии/маршруты; иначе — один shipment.
    Даже если несколько адресов забора для груза, но адресс доставки один, то формируй 1 shipment.
    Альтернативные значения: если в письме для одного параметра указано несколько возможных значений (например, два варианта веса, два порта отправления, несколько адресов доставки на выбор), **не суммируй их и не выбирай одно**. Перечисли все варианты через " ИЛИ " (с пробелами). Примеры:
    - "24240 кг ИЛИ 26260 кг"
    - "FOB Dalian ИЛИ FCA Shenyang"
    Исключение - адрес доставки, потому что они могут быть все необходимы и на кажом будет выгрузка.
    Запрещено логически предполагать штабелируемость. stackable_with_others и stackable_among_themselves ставь true/false только при прямом указании в письме/packing list/вложении. Если явного указания нет — строго null.
    document_replacement_needed — true/false только при явной формулировке о замене документов; иначе null.
    dangerous_goods:
        явное «груз не опасный» (non-DG, not dangerous, не опасный) → все вложенные поля false, а также msds_required = false, dgm_report_required = false.
        явный класс/тип опасности без указания конкретного груза → dangerous_goods заполняется значением из cargo_description, msds_required = true.
        иначе, если нет явного подтверждения/отрицания — dangerous_goods = null.
    additional_services — добавляй любые услуги/требования из письма, которые не подходят под другие категории (включая: действия с документами, кроме замены; особые требования к штабелированию; особые требования к перевозке(штрафы, требования к машине или перевозке, желаемое время и тд.)). Не перефразируй, не придумывай исполнителя; если указано только кто платит — сохрани как есть. Игнорируй услугу только если явно сказано, что она не нужна.
    client_name чаще всего указывается в названии письма(дисклеймере)
    cargo_ready_date — дата готовности (ready date, ETD от производителя, «готов к отгрузке с …»). Может быть строкой или массивом строк для разных складов/партий.
    battery_mode: "В составе" (встроены/упакованы с оборудованием) или "Отдельно" (перевозятся отдельно).
    Если упоминается необходимость «разрыва накладных» — это равносильно «перелёт через третью страну нужен» → transshipment_with_third_country = true.
    shipping_options — только явно описанные варианты с ценами/сроками, если есть. Не выдумывай.
    container_type — строго! формат "9x40HC" и т.п. (количество, а после тип), не наоборот,обязательно меняй на "nx(тип контейнера)" где n - это количество, если точное количество не указывается, передай как написано в тексте.
    pickup_address - если адрес дан в виде иероглифов, то переводи его на английский.
    В массив other_things вставляй наименование груза при нахождении упоминаний о классе опасности. Только если нет чёткого ответа на то какой груз опасный!
    Всегда создавай отдельный shipment для каждой партии груза, если у них различаются:
    – описание груза (cargo_description),
    – наличие опасных свойств (dangerous_goods),
    – адрес забора (pickup_address).
    Если в письме перечислены два явно разных груза с разными характеристиками, помещай их в разные объекты массива shipments.

PREFLIGHT
    Блок preflight — только ориентир для shipping_type и shipment_type. При противоречии с письмами/вложениями приоритет за письмами/вложениями. Не дублируй вывод preflight.

ТИПЫ ПЕРЕВОЗКИ
 {shipping_types_json_compact}
    requested_shipping_type_names: массив точных name из списка, которые явно запрошены/сравниваются для данной партии.
        Один однозначный способ → массив из одного имени.
        «Любой вариант»/«все» → все релевантные имена.
        Нет явных признаков → [] и shipping_type = "".
    shipping_type: заполняется значением из requested_shipping_type_names только когда там ровно один элемент; иначе "".
    Мультимодальность (море+ж/д) ставь только для одной связанной цепочки маршрута. «Море ИЛИ ж/д» — не мультимодальность.
    FCL/LCL — контейнерные перевозки (море/жд); FTL/LTL — автодорожные.

СОГЛАСОВАНИЕ ПОЛЕЙ
    shipment_type: только "FCL", "LCL" или "". Если в shipping_type есть (FCL)/(LCL), согласуй.
    container_type — для контейнерных море/жд; vehicle_type и vehicle_dimensions — для автодороги.
    vehicle_dimensions — только габариты транспортного средства, не груза; при отсутствии [].

ПРАВИЛА ЗАПОЛНЕНИЯ ПОЛЕЙ
    ID_emails — список ID писем, из которых взята информация для перевозки.
    dimensions — массив объектов. количество объектов должно строго соответствовать количеству мест в packing list. если у коробок/паллет одинаковые габариты и вес, объединяй их в диапазоны (например, места 1-31), при этом их вес (weight_kg) и объём (volume_cbm) суммируй за всю группу, а если место уникальное — оставляй отдельным объектом. поля length_cm, width_cm, height_cm всегда в сантиметрах. если размеры указаны в мм или без единиц (например, 1200×800×600), переводи в см и указывай dimension_unit: "cm"|"mm"|"m". если в packing list/вложении есть вес места (carton weight, pallet weight, net/gross weight per package) или объём (cbm per package, volume per pallet) — сохраняй как weight_kg, volume_cbm внутри объекта. Если габариты (length_cm, width_cm, height_cm) для места неизвестны, но указаны вес, объём, тип места или описание, всё равно создавай объект без полей length_cm/width_cm/height_cm. Например: «Место 3: рулон, 58 кг, 0.271 м³» → создай три объекта или диапазон с place_type "рулон", weight_kg=58, volume_cbm=0.271. Аналогично, если известно только общее описание тоест («20 паллет; 320 коробок») без точных габаритов, создавай объекты с указанием количества мест (например, place_no: "1-20", description: "паллета").    total_weight_kg: если груз в контейнере, возвращай вес, соответствующий одному контейнеру; не суммируй, если общий вес уже указан для всей перевозки.
    special_transport_requirements - если ты нашёл чем заполнить это поле, то добавь это же и в additional services
    msds_required - если ты считаешь что msds нужен, то учти что он должен быть представлен именно документом формата pdf.
    dgm_report_required - ставь True, если в грузе есть батарейки.
    delivery_address - втсавляй сюда любые точки назначения, если есть точный адрес то супер, если только порт или аэропорт - тоже сгодится.
    Если в тексте ты встретил что-то по типу "Нужно ТО"или "Таможенное оформление в Минске" то ставь customs_clearance_required: true, а customs_clearance_place_export_rf само место
    Используй только явные данные. Не додумывай.
    Ссылка на ISO-контейнеры: {container_iso_ref_block} (использовать как справочник).
ФОРМАТ ВЫВОДА
Строгий JSON с соблюдением всех указанных правил, без комментариев.
ТРЕБУЕМЫЙ ФОРМАТ ОТВЕТА (ТОЛЬКО JSON):
{{
    "shipments": [
        {{
            "ID_emails": ["список ID писем, откуда взята информация"],
            "client_name": "название компании-клиента",
            "incoterms": "условия поставки Incoterms",
            "cargo_ready_date": "дата(ы) готовности груза к отгрузке: одна строка или массив строк; если для разных мест/партий указаны разные даты — перечисли все (позже отобразятся через запятую)",
            "pickup_address": "адрес забора груза",
            "cargo_value": "стоимость груза с валютой (строка, например '1000 USD')",
            "package_count": "10",
            "total_weight_kg": "150.5",
            "dimensions": [
                {{"place_no": 1, "place_type": "коробка", "length_cm": "100", "width_cm": "80", "height_cm": "120", "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,
                "other_things" : []
            }},
            "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": "1x40HQ, 2x20DC",
            "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, иначе \"\""
            ]
        }}
    ]
}}
Контекст из писем для анализа:
{context_text}
Все ответы должны быть обязательно на русском
Верни ТОЛЬКО валидный JSON-объект, начинающийся с {{ и заканчивающийся }}. Никакого дополнительного текста.
"""
        user_query = query_text

        # --- ЕДИНЫЙ ВЫЗОВ LLM ---
        try:
            response = self.openai_client.chat.completions.create(
                model="deepseek-v4-flash",
                messages=[
                    {"role": "system", "content": system_prompt},
                    {"role": "user", "content": user_query}
                ],
                temperature=0.0,
                top_p=0.1,
                frequency_penalty=0.0,
                presence_penalty=0.0
            )
            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)
        for s in shipments:
            self._enrich_additional_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

                        # --- попытка взять тип из preflight ---
            pf = structured_data.get("preflight_classification")
            pf_suggestion = pf.get("shipping_type_suggestion") if isinstance(pf, dict) else None
            if isinstance(pf_suggestion, str) and pf_suggestion.strip():
                pf_record = self._shipping_type_record_by_name(pf_suggestion.strip())
                if pf_record:
                    st_name = pf_record.get("name") or ""
                    criteria_ai = pf_record.get("criteria_ai") or pf_record.get("criteria") or ""
                    cstr = str(criteria_ai)
                    shipment["shipping_type_candidates"] = [
                        {"shipping_type": st_name, "match_score": 0, "criteria": cstr}
                    ]
                    shipment["shipping_type"] = st_name if len(shipment.get("requested_shipping_type_names", [])) <= 1 else ""
                    shipment["criteria"] = cstr
                    shipment["criteria_preview"] = ""
                    logger.info("Shipping type from preflight: %s", st_name)
                    self._reconcile_shipment_load_fields(shipment, combined_text)
                    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 [])
            if recipient_emails:
                matching_types = self._shipping_types_matching_recipients(recipient_emails)
                if matching_types:
                    candidates = []
                    names = []
                    for st in matching_types:
                        name = st.get("name")
                        if name:
                            criteria_ai = st.get("criteria_ai") or st.get("criteria") or ""
                            candidates.append({
                                "shipping_type": name,
                                "match_score": 0,
                                "criteria": str(criteria_ai)
                            })
                            names.append(name)
                    shipment["shipping_type_candidates"] = candidates
                    shipment["requested_shipping_type_names"] = names
                    shipment["shipping_type"] = ""          # оставляем пустым, т.к. вариантов несколько
                    shipment["criteria"] = ""
                    shipment["criteria_preview"] = ""
                    logger.info("Shipping types by recipients: %s (emails: %s)", names, sorted(recipient_emails))
                else:
                    # нет типов для этих адресатов – оставляем candidates пустыми
                    shipment["shipping_type_candidates"] = []
                    shipment["requested_shipping_type_names"] = []
                    shipment["shipping_type"] = ""
                    shipment["criteria"] = ""
                    shipment["criteria_preview"] = ""
            else:
                shipment["shipping_type_candidates"] = []
                shipment["requested_shipping_type_names"] = []
                shipment["shipping_type"] = ""
                shipment["criteria"] = ""
                shipment["criteria_preview"] = ""

            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
