import os import logging import hashlib import json import re import time import base64 from typing import Dict, List, Optional, Any, Union, Tuple from datetime import datetime from openai import OpenAI from dotenv import load_dotenv from document_processor import DocumentProcessor from shipping_calculator import calculate_shipping_cost try: from container_reference import iso_reference_prompt_block except ImportError: def iso_reference_prompt_block() -> str: return "" load_dotenv() logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') _SHIPPING_DIR = os.path.dirname(__file__) def _normalize_dimension_unit_field(raw: Any) -> Optional[str]: """Значение поля unit / dimension_unit из JSON: cm | mm | m или None.""" if raw is None: return None s = str(raw).strip().lower() if s in ("mm", "мм", "millimeter", "millimeters", "миллиметр", "миллиметры"): return "mm" if s in ("cm", "см", "centimeter", "centimeters", "сантиметр", "сантиметры"): return "cm" if s in ("m", "meter", "metre", "metres", "meters", "метр", "метры", "м"): return "m" return None def _line_hint_dimension_unit(line: str) -> Optional[str]: """По строке письма/таблицы: явные см/мм рядом с габаритами.""" if not isinstance(line, str) or not line.strip(): return None ll = line.lower() has_mm = bool(re.search(r"(? Tuple[float, float, float]: """ Тройка чисел без суффикса _mm в JSON: часто см или мм. Раньше max>120 считалось мм и делилось на 10 — ломало типичные см (150×80×60). Явная единица (поле или текст) имеет приоритет; иначе осторожная эвристика. """ if explicit_unit == "mm": return (a / 10.0, b / 10.0, c / 10.0) if explicit_unit == "m": return (a * 100.0, b * 100.0, c * 100.0) if explicit_unit == "cm": return (a, b, c) mx, mn = max(a, b, c), min(a, b, c) # Типичные размеры коробки в мм как «сырые» числа в полях length_cm: 1200×800×600 if mx >= 1000.0: return (a / 10.0, b / 10.0, c / 10.0) # Плоские места в мм: 1200×800×40 и т.п. if mx >= 600.0 and mn <= 100.0: return (a / 10.0, b / 10.0, c / 10.0) # Иначе считаем, что уже сантиметры (в т.ч. 150×80×60, 400×300×200) return (a, b, c) 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] PROCESSED_SHIPPING_TYPES_FILE = os.path.join( _SHIPPING_DIR, "shipping_types_processed.json" ) def resolve_shipping_types_path() -> Optional[str]: for path in SHIPPING_TYPES_CANDIDATE_FILES: if os.path.exists(path): return path return None def _fallback_process_shipping_type_criteria_static(criteria_text: str) -> str: """ Быстрый детерминированный fallback для criteria_ai. Это нужно, чтобы отчёты сразу перестали показывать "сырой" длинный список, даже если файл shipping_types_processed.json ещё не заполнен. """ if not isinstance(criteria_text, str) or not criteria_text.strip(): return "" text = criteria_text.lower() label_rules = [ ("название клиента" in text, "Клиент"), (("код тн вэд" in text) or ("код тн" in text), "Код ТН ВЭД"), (("инкотерм" in text), "Условия поставки Incoterms"), (("адрес забора" in text) or ("порт отправления" in text) or ("порт погрузки" in text), "Адрес забора груза"), (("адрес доставки" in text) or ("порт назначения" in text) or ("порт выгрузки" in text), "Адрес доставки"), (("стоимость груза" in text), "Стоимость груза"), (("количество грузовых мест" in text) or ("количество мест" in text), "Количество грузовых мест"), (("общий вес" in text), "Вес груза"), ( ("габарит" in text) and ( "транспортн" in text or "машин" in text or "средств" in text or "кузов" in text or "прицеп" in text or "полуприцеп" in text ), "Габариты машины", ), (("габарит" in text), "Габариты"), (("общий объ" in text) or ("общий объем" in text) or ("объём" in text), "Объём"), (("характер груза" in text) or ("наименование груза" in text), "Характер груза"), (("опасные вещества" in text) or ("опасные свойства" in text), "Опасные свойства"), (("msds" in text) or ("паспорт безопасности" in text), "Требуется MSDS"), (("dgm" in text) or ("dangerous goods management" in text), "Требуется DGM"), (("авторизац" in text), "Разрешение бренда"), (("название бренда" in text) or ("необходимо указать название бренда" in text), "Бренд"), (("экспортная лиценз" in text) or ("экспортной лиценз" in text) or ("экспорт" in text and "лиценз" in text), "Лицензия экспортёра"), (("температурный режим" in text) or ("температур" in text), "Температурный режим"), ] labels: List[str] = [label for ok, label in label_rules if ok] deduped = list(dict.fromkeys(labels)) return "; ".join(deduped) if deduped else "" #============================================================================= #Словарь соответствия технических ключей и понятных названий #============================================================================= FIELD_LABELS = { "client_name": "Клиент", "incoterms": "Условия поставки Incoterms", "cargo_ready_date": "Дата готовности груза", "pickup_address": "Адрес забора груза", "delivery_address": "Адрес доставки", # добавляем синонимы "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": "Место таможенного оформления (экспорт РФ)", "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": v = shipment.get("customs_clearance_place_export_rf") if v is None or (isinstance(v, str) and not str(v).strip()): out.append(field) continue if field == "total_volume_cbm": val = shipment.get("total_volume_cbm") if val is None or val == "": out.append(field) elif isinstance(val, (int, float)) and float(val) <= 0: out.append(field) continue return out def infer_container_load_mode_from_text(text_lower: str) -> Optional[str]: """ Оценка FCL/LCL по тексту (контейнерные море/ж/д). None — если противоречиво или нет признаков. """ if not text_lower or not isinstance(text_lower, str): return None t = text_lower.lower() lcl = bool( re.search( r"\blcl\b|сборн\w*(\s+груз|\s+контейнер|\s+перевоз|\s+отправк)|" r"группаж|консолидац\w*|consolidat\w*|groupage|" r"less\s*than\s*container|неполн\w*\s+контейнер|" r"дол\w*\s+в\s+контейнер|ко[-\s]?лоад|cfs\b|lcl\s*cargo", t, re.IGNORECASE, ) ) fcl = bool( re.search( r"\bfcl\b|full\s*container\s*load|" r"цельн\w*\s+контейнер|полн\w*\s+контейнер|" r"отдельн\w*\s+контейнер|exclusive\s*use|" r"soc\s*container|shipper['\u2019]s?\s*own\s*container", t, re.IGNORECASE, ) ) if lcl and not fcl: return "LCL" if fcl and not lcl: return "FCL" if lcl and fcl: return None if re.search( r"\d{1,3}\s*[x×х*]\s*(20|40|45)\s*['′'`´]?\s*" r"(dc|hc|hq|gp|dv|dry|rf|rh|reefer|ot|fr|tk|tank)?\b", t, re.IGNORECASE, ): return "FCL" return None def _shipping_type_name_implies_load_mode(name: str) -> Optional[str]: if not name: return None n = name.lower() if "(lcl)" in n: return "LCL" if "(fcl)" in n: return "FCL" return None def _is_parallel_multi_cargo_order_table(text: str) -> bool: """ Несколько самостоятельных партий в одной таблице (столбцы с разными номерами груза/заказа). Отличается от тендера: разные инвойсы, разные Total, разные требования по DG — это НЕ один shipment. """ if not isinstance(text, str) or len(text.strip()) < 80: return False tl = text.lower() if not re.search(r"номер\s+груз", tl): return False troc = re.findall(r"\btrocyps[-_]?\d+\b", text, flags=re.IGNORECASE) uniq_troc = {x.lower().replace("_", "-") for x in troc} if len(uniq_troc) >= 2: if tl.count("total:") >= 2 or len(re.findall(r"\$\s*[\d\s]{4,}", text)) >= 2: return True return False def _strong_invoice_value_mismatch(a: str, b: str) -> bool: """Два ненулевых инвойса в $ с отношением >= 2 — разные партии, не склеивать агрессивно.""" def _grab_usd(s: str) -> Optional[float]: if not isinstance(s, str): return None m = re.search(r"\$\s*([\d\s.,]+)", s) if not m: return None raw = m.group(1).replace(" ", "").replace("\xa0", "") if raw.count(",") == 1 and raw.count(".") == 0 and len(raw.split(",")[-1]) == 2: raw = raw.replace(",", ".") else: raw = raw.replace(",", "") try: v = float(raw) return v if v > 0 else None except ValueError: return None x, y = _grab_usd(a or ""), _grab_usd(b or "") if x is None or y is None: return False lo, hi = (x, y) if x < y else (y, x) return (hi / lo) >= 2.0 def _is_tender_multi_origin_context(text: str) -> bool: """ Тендер / одна заявка с несколькими адресами забора и одной выгрузкой. Такие письма нельзя дробить на отдельные shipments по каждой точке. """ if not isinstance(text, str) or len(text.strip()) < 120: return False if _is_parallel_multi_cargo_order_table(text): return False t = text.lower() score = 0 if re.search(r"тендер", t): score += 2 if re.search( r"приём\s+предложений|прием\s+предложений|приём\s+оферт|прием\s+оферт", t, ): score += 1 if re.search(r"адрес\s+загрузки\s*\d+", t): score += 2 n_pickups = len(re.findall(r"адрес\s+загрузки\s*\d+", t)) if n_pickups >= 3: score += 3 elif n_pickups >= 2: score += 1 if re.search(r"\bплеч\w*\b", t) and re.search(r"(?:жд|море|порт|станц)", t): score += 1 if re.search(r"\bozon\b", t) and (n_pickups >= 2 or "стм" in t): score += 1 return score >= 4 # Английские плейсхолдеры в confirmation_template: (Label) -> поле данных BRACKET_LABEL_ALIASES_EN: List[tuple[str, str]] = [ ("Pickup address", "pickup_address"), ("Delivery address", "delivery_address"), ("Port of loading", "loading_port"), ("Port of discharge", "discharge_port"), ("Cargo weight", "total_weight_kg"), ("Number of packages", "package_count"), ("Volume", "total_volume_cbm"), ("Dimensions", "dimensions_str"), ("Cargo description", "cargo_description"), ("HS code", "hs_code"), ("Dangerous properties", "dangerous_goods_str"), ("MSDS required", "msds_required"), ("Freight cost", "estimated_cost"), ("Transit time", "estimated_transit_time"), ("Shipment type", "shipment_type"), ("Container type", "container_type"), ("Vehicle type", "vehicle_type"), ("Vehicle dimensions", "vehicle_dimensions_str"), ("Temperature regime", "temperature_range"), ("Temperature range", "temperature_range"), ] #============================================================================= # ОТОБРАЖЕНИЕ КОНТЕЙНЕРОВ (Nxтип без голых 40HC/20DC) #============================================================================= _CONTAINER_BARE_ONLY = re.compile( r"^\s*(20|40|45)\s*['′'`´]?\s*(?:ft|feet|ф)?\s*" r"(dc|hc|hq|gp|dv|dry|rf|rh|reefer|ref|ot|fr|tk|tank)\s*$", re.IGNORECASE, ) _CONTAINER_NX = re.compile( r"(?\d{1,3})\s*[x×х**]\s*" r"(?P(?:20|40|45)\s*['′'`´]?\s*(?:ft|feet|ф)?\s*" r"(?:dc|hc|hq|gp|dv|dry|rf|rh|reefer|ref|ot|fr|tk|tank)\b)", re.IGNORECASE, ) def _normalize_container_token(n_str: str, type_part: str) -> str: n = int(n_str) t = re.sub(r"\s+", "", type_part.strip()) return f"{n}x{t}" def normalize_container_type_display(raw: Optional[str], *, empty: str = "") -> str: if raw is None: return empty s = str(raw).strip() if not s: return empty tokens: List[str] = [] for m in _CONTAINER_NX.finditer(s): tokens.append(_normalize_container_token(m.group("n"), m.group("t"))) if tokens: return ", ".join(tokens) kept: List[str] = [] for part in re.split(r"[,;]+", s): p = part.strip() if not p: continue if _CONTAINER_BARE_ONLY.match(p): continue if _CONTAINER_NX.search(p): for m in _CONTAINER_NX.finditer(p): kept.append(_normalize_container_token(m.group("n"), m.group("t"))) continue if re.match(r"^\d+\s*[x×х**]", p, re.IGNORECASE): kept.append( re.sub(r"\s*([x×х**])\s*", "x", p, count=1, flags=re.IGNORECASE) ) return ", ".join(kept) if kept else empty #============================================================================= #КЛАСС ДЛЯ АВТОЗАПОЛНЕНИЯ ШАБЛОНОВ #============================================================================= class AutoFillDict(dict): """ Словарь, который автоматически возвращает значения для любых ключей. Если ключ не найден — пытается найти по понятному названию (из FIELD_LABELS). Идеально для шаблонов писем без явных {placeholder}. """ def __init__(self, data: dict, field_labels: dict = None): super().__init__(data) self._data = data self._field_labels = field_labels or FIELD_LABELS self._label_to_field = {v: k for k, v in self._field_labels.items()} def __missing__(self, key): # Если ключ есть в оригинальных данных — возвращаем значение if key in self._data: val = self._data[key] return str(val) if val is not None else "" # Пытаемся найти по понятному названию (например "Клиент" → "client_name") if key in self._label_to_field: field_key = self._label_to_field[key] if field_key in self._data: val = self._data[field_key] return str(val) if val is not None else "" # Если не нашли — возвращаем пустую строку (чтобы не было ошибок) return "" #============================================================================= #ФУНКЦИЯ АВТОЗАПОЛНЕНИЯ ШАБЛОНА #============================================================================= def auto_fill_template(template: str, data: Dict, field_labels: Dict = None) -> str: """ Автоматически находит и подставляет значения полей в шаблон. Поддерживает два формата: - {technical_key} — технический ключ - (Понятное название) — русское название в скобках - (English label) — английские подписи из BRACKET_LABEL_ALIASES_EN ❌ НЕ заменяет plain text без скобок! """ field_labels = field_labels or FIELD_LABELS label_to_field = {v: k for k, v in field_labels.items()} result = template # Создаём словарь для подстановки auto_data = AutoFillDict(data, field_labels) # 1. Сначала обрабатываем явные {placeholder} try: result = result.format(**auto_data) except KeyError: pass # 2. Обрабатываем (Понятное название) в скобках — русские и английские алиасы merged_pairs = list(label_to_field.items()) + BRACKET_LABEL_ALIASES_EN for label, field_key in merged_pairs: if field_key in data: value = data[field_key] value_str = str(value).strip() if value is not None and str(value).strip() else "не указан" # Паттерн для (Вес), (Адрес забора) и т.д. pattern = r'\(' + re.escape(label) + r'\)' result = re.sub(pattern, value_str, result, flags=re.IGNORECASE) return result #=============================================================================# #ЗАГРУЗКА ТИПОВ ПЕРЕВОЗОК #============================================================================= def load_shipping_types() -> List[Dict]: """Загружает типы перевозок из JSON-файла с нормализацией ключей и значений.""" types_path = resolve_shipping_types_path() if not types_path: logger.warning( "Файл типов перевозок не найден (ожидались shipping_types.json или shipping_types(1).json)" ) return [] try: with open(types_path, "r", encoding="utf-8") as f: raw_types = json.load(f) if not isinstance(raw_types, list): logger.error(f"Expected list in shipping_types.json, got {type(raw_types)}") return [] normalized_types = [] for t in raw_types: if not isinstance(t, dict): continue normalized = {} for key, value in t.items(): clean_key = key.strip().strip('"').strip("'") if clean_key == "keywords" and isinstance(value, list): cleaned_keywords = [] for kw in value: if isinstance(kw, str): kw_clean = kw.strip().strip('"').strip("'").strip() if not kw_clean: continue if ' ' in kw_clean and len(kw_clean) > 2000: parts = [p.strip().strip('"').strip("'") for p in kw_clean.split() if p.strip()] cleaned_keywords.extend(parts) else: cleaned_keywords.append(kw_clean) normalized[clean_key] = cleaned_keywords elif isinstance(value, str): normalized[clean_key] = value.strip() else: normalized[clean_key] = value if "id" not in normalized: normalized["id"] = len(normalized_types) + 1 normalized_types.append(normalized) # Подмешиваем обработанные (ИИ) критерии, если файл существует. processed_map: Dict[str, str] = {} if os.path.exists(PROCESSED_SHIPPING_TYPES_FILE): try: with open(PROCESSED_SHIPPING_TYPES_FILE, "r", encoding="utf-8") as pf: raw_processed = json.load(pf) if isinstance(raw_processed, dict): processed_map = {str(k): str(v) for k, v in raw_processed.items() if v is not None} except Exception as e: logger.warning(f"Failed to load processed shipping criteria: {e}") # criteria_ai: # 1) если есть обработанный ИИ-результат в shipping_types_processed.json — используем его # 2) иначе — используем исходный criteria, чтобы отчёт не "терял" пункты for t in normalized_types: name = t.get("name") raw_criteria = t.get("criteria", "") if isinstance(t, dict) else "" processed_val = None if isinstance(name, str): processed_val = processed_map.get(name) # Если processed_val выглядит как "старый" короткий список (без \t), # используем исходные критерии, чтобы не терять пункты. if isinstance(processed_val, str) and processed_val.strip() and "\t" in processed_val: t["criteria_ai"] = processed_val else: t["criteria_ai"] = raw_criteria logger.info(f"Loaded {len(normalized_types)} shipping types") return normalized_types except json.JSONDecodeError as e: logger.error(f"JSON decode error in shipping_types.json: {e}") return [] except Exception as e: logger.error(f"Error loading shipping types: {e}") return [] #============================================================================= #НОРМАЛИЗАЦИЯ КЛЮЧЕЙ СЛОВАРЯ #============================================================================= def _normalize_dict_keys(data: Any) -> Any: """Рекурсивно нормализует ключи словаря — убирает пробелы и кавычки""" if isinstance(data, dict): return {k.strip().strip('"').strip("'"): _normalize_dict_keys(v) for k, v in data.items()} elif isinstance(data, list): return [_normalize_dict_keys(item) for item in data] else: return data #============================================================================= #RAG-ДВИЖОК #============================================================================= class RAGEngineGemini: """RAG-движок для анализа писем о грузоперевозках и генерации ответов.""" def __init__(self, api_base_url: str = "http://localhost:8090/v1"): openai_api_key = os.getenv("OPENAI_API_KEY") if not openai_api_key: raise ValueError("OPENAI_API_KEY not found in environment variables") self.openai_client = OpenAI( api_key=openai_api_key, base_url=api_base_url ) self.sessions: Dict[str, List[Dict]] = {} self.doc_processor = DocumentProcessor() self.shipping_types = load_shipping_types() logger.info(f"RAGEngineGemini initialized with {len(self.shipping_types)} shipping types") def reload_shipping_types(self) -> None: """Reload shipping types from disk (used after admin updates).""" self.shipping_types = load_shipping_types() logger.info(f"Shipping types reloaded: {len(self.shipping_types)} types") def _fallback_process_shipping_type_criteria(self, criteria_text: str) -> str: """ Детерминированная эвристика, если ИИ не доступен. Возвращает короткий список критериев в формате: "A; B; C". """ if not isinstance(criteria_text, str) or not criteria_text.strip(): return "" text = criteria_text.lower() label_rules = [ ("название клиента" in text, "Клиент"), (("код тн вэд" in text) or ("код тн" in text), "Код ТН ВЭД"), (("инкотерм" in text), "Условия поставки Incoterms"), (("адрес забора" in text) or ("порт отправления" in text) or ("порт погрузки" in text), "Адрес забора груза"), (("адрес доставки" in text) or ("порт назначения" in text) or ("порт выгрузки" in text), "Адрес доставки"), (("стоимость груза" in text), "Стоимость груза"), (("количество грузовых мест" in text) or ("количество мест" in text), "Количество грузовых мест"), (("общий вес" in text), "Вес груза"), ( ("габарит" in text) and ( "транспортн" in text or "машин" in text or "средств" in text or "кузов" in text or "прицеп" in text or "полуприцеп" in text ), "Габариты машины", ), (("габарит" in text), "Габариты"), (("общий объ" in text) or ("общий объем" in text) or ("объём" in criteria_text.lower()), "Объём"), (("характер груза" in text) or ("наименование груза" in text), "Характер груза"), (("опасные вещества" in text) or ("опасные свойства" in text) or ("batter" in text), "Опасные свойства"), (("msds" in text) or ("паспорт безопасности" in text), "Требуется MSDS"), (("dgm" in text) or ("dangerous goods management" in text), "Требуется DGM"), (("авторизац" in text), "Разрешение бренда"), (("необходимо указать название бренда" in text) or ("название бренда" in text), "Бренд"), (("экспортн" in text) and ("лиценз" in text) or ("экспортная лиценз" in text) or ("экспортной лиценз" in text), "Лицензия экспортёра"), (("температурный режим" in text) or ("температур" in text), "Температурный режим"), ] labels: List[str] = [label for ok, label in label_rules if ok] # убираем дубликаты, сохраняя порядок deduped = list(dict.fromkeys(labels)) if not deduped: return "" return "; ".join(deduped) def process_shipping_type_criteria(self, criteria_text: str) -> str: """ Прогоняет criteria из shipping_type через ИИ и возвращает ОЧИЩЕННУЮ версию criteria (с сохранением нумерованного формата), чтобы отчёт показывал ровно пункты из criteria. """ def _normalize_criteria_text(text: str) -> str: if not isinstance(text, str): return "" lines = [ln.strip() for ln in text.splitlines() if ln.strip()] normalized: List[str] = [] for idx, ln in enumerate(lines, start=1): m = re.match(r"^\s*(\d+)[\.\)\-]?\s*(.*)$", ln) if m: num = m.group(1).strip() body = m.group(2).strip() if body: normalized.append(f"{num}\t{body}") else: body = ln normalized.append(f"{idx}\t{body}") return "\n".join(normalized) fallback = _normalize_criteria_text(criteria_text.strip()) if isinstance(criteria_text, str) else "" if not isinstance(criteria_text, str) or not criteria_text.strip(): return "" system_prompt = ( "Ты инженер по логистике и формированию отчётов. " "Тебе дан текст критерием из поля shipping_type (обычно нумерованный список вида: \"1\\t...\\n2\\t...\"). " "Твоя задача: сделать формулировки критериев более аккуратными и единообразными, но без потери смысла. " "Нельзя удалять пункты или объединять их. " "Верни строго нумерованный список в формате: <номер>\\t<критерий>, каждый пункт с новой строки. " "Возвращай ответ строго в JSON-формате: {\"processed_criteria\": \"...\"}." ) user_prompt = f"Исходные критерии shipping_type:\n{criteria_text}" try: response = self.openai_client.chat.completions.create( model="card_generation", messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], temperature=0, max_tokens=250, ) answer_text = response.choices[0].message.content or "" # ожидаем {"processed_criteria":"..."} parsed = self._parse_json_response(answer_text) processed = parsed.get("processed_criteria") if isinstance(processed, str): processed = _normalize_criteria_text(processed.strip()) if processed: return processed except Exception as e: logger.warning(f"AI processing criteria failed, fallback to original criteria: {e}") return fallback async def process_outlook_emails(self, emails: List[Dict], session_id: Optional[str] = None) -> str: try: if session_id is None or session_id not in self.sessions: session_id = hashlib.md5(f"{time.time()}{str(emails)}".encode()).hexdigest()[:16] self.sessions[session_id] = [] logger.info(f"Created new session {session_id}") for email in emails: email_text, attachments = self._extract_email_content(email) shipping_type = self._detect_shipping_type_from_email(email_text) if shipping_type: logger.info(f"Detected shipping type: {shipping_type}") self.sessions[session_id].append({ "content": email_text, "metadata": { "subject": email.get("subject", ""), "sender": email.get("sender", ""), "sender_name": email.get("senderName", ""), "received_time": str(email.get("receivedTime", "")), "email_id": email.get("id"), "to": email.get("to", ""), "cc": email.get("cc", "") }, "attachments": attachments, "shipping_type": shipping_type }) logger.info(f"Indexed email {email.get('id')} with session_id {session_id}") return session_id except Exception as e: logger.error(f"Error processing Outlook emails: {e}", exc_info=True) raise def _text_requests_all_shipping_types(self, text: str) -> bool: """ Клиент просит варианты по всем способам доставки (а не один конкретный тип). """ if not text or not isinstance(text, str): return False t = text.lower() patterns = [ r"все\s+возможн\w*\s+тип\w*\s+(?:доставк|перевозк|отправк)", r"все\s+тип\w*\s+(?:доставк|перевозк|отправк)", r"все\s+вариант\w*\s+(?:доставк|перевозк|доставки|перевозки)", r"все\s+способ\w*\s+доставк", r"по\s+всем\s+тип\w*\s+(?:доставк|перевозк)", r"по\s+всем\s+способ\w*\s+доставк", r"люб\w*\s+(?:из\s+)?тип\w*\s+доставк", r"люб\w*\s+способ\w*\s+доставк", r"люб\w*\s+вариант\w*\s+доставк", r"все\s+канал\w*\s+доставк", r"все\s+модальност\w*", r"люб\w*\s+вид\w*\s+доставк", r"all\s+(?:possible\s+)?types?\s+of\s+(?:delivery|shipping|transport|freight)", r"all\s+(?:shipping|delivery)\s+options?", r"any\s+(?:type\s+of\s+)?(?:delivery|shipping|freight)", r"every\s+(?:shipping|delivery)\s+(?:type|option|mode)", ] return any(re.search(p, t, re.IGNORECASE) for p in patterns) def _detect_shipping_type_from_email(self, email_text: str) -> Optional[str]: """Тип перевозки выбирает основной LLM; при индексации писем не подставляем тип по ключевым словам.""" return None def _extract_emails_from_text(self, raw: Any) -> List[str]: if not raw: return [] text = str(raw).lower() return list(dict.fromkeys(re.findall(r"[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}", text))) def _recipients_for_shipment(self, shipment: Dict, sources: List[Dict]) -> set[str]: if not isinstance(shipment, dict) or not isinstance(sources, list): return set() email_ids = set(shipment.get("ID_emails") or []) if not email_ids: return set() recipients: set[str] = set() for src in sources: if not isinstance(src, dict): continue if src.get("id") not in email_ids: continue recipients.update(self._extract_emails_from_text(src.get("to"))) recipients.update(self._extract_emails_from_text(src.get("cc"))) return recipients def _shipping_types_matching_recipients(self, recipients: set[str]) -> List[Dict]: if not recipients or not self.shipping_types: return [] matches: List[Dict] = [] for st in self.shipping_types: if not isinstance(st, dict): continue raw = st.get("employee_email") or "" bound_emails = set(self._extract_emails_from_text(raw)) if bound_emails and (bound_emails & recipients): matches.append(st) return matches def _pick_recipient_shipping_type( self, recipients: set[str], combined_text: str ) -> Optional[Dict]: """ Если на ящик заведено несколько типов — без скоринга по тексту: первый совпавший в порядке записей в shipping_types (combined_text зарезервирован под будущие правила). """ _ = combined_text matches = self._shipping_types_matching_recipients(recipients) if not matches: return None return matches[0] def _shipping_type_by_recipient(self, recipients: set[str]) -> Optional[Dict]: m = self._shipping_types_matching_recipients(recipients) return m[0] if m else None def 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") 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): # если вообще ничего не указано → всё в None if not any(v is True for v in dg.values()): 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 = 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")) # Если пришли *_mm — переводим в см. used_mm = False if l_cm is None and d.get("length_mm") is not None: l_mm = _to_float(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(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(d.get("height_mm")) if h_mm is not None: h_cm = h_mm / 10.0 used_mm = True # Если пришло без суффикса — считаем, что это см, но при явных mm-сайзах конвертим. if l_cm is None and d.get("length") is not None: l_cm = _to_float(d.get("length")) if w_cm is None and d.get("width") is not None: w_cm = _to_float(d.get("width")) if h_cm is None and d.get("height") is not None: h_cm = _to_float(d.get("height")) if l_cm is None or w_cm is None or h_cm is None: return None unit_key = _normalize_dimension_unit_field( d.get("dimension_unit") or d.get("unit") or d.get("dimensions_unit") ) if not used_mm: if has_explicit_cm_keys: # Для *_cm не применяем unit=mm повторно. # Исключение: явно аномально большие значения — вероятно, это всё же мм. mx = max(l_cm, w_cm, h_cm) if mx >= 1000: l_cm, w_cm, h_cm = infer_cargo_triple_raw_to_cm( l_cm, w_cm, h_cm, explicit_unit="mm" ) elif unit_key is None: l_cm, w_cm, h_cm = infer_cargo_triple_raw_to_cm(l_cm, w_cm, h_cm) elif unit_key == "mm": l_cm, w_cm, h_cm = infer_cargo_triple_raw_to_cm( l_cm, w_cm, h_cm, explicit_unit="mm" ) elif unit_key == "m": l_cm, w_cm, h_cm = infer_cargo_triple_raw_to_cm( l_cm, w_cm, h_cm, explicit_unit="m" ) elif unit_key == "cm": pass # Финальная валидация (не допускаем нули/отрицательные). if l_cm <= 0 or w_cm <= 0 or h_cm <= 0: return None return { "length_cm": round(l_cm, 4), "width_cm": round(w_cm, 4), "height_cm": round(h_cm, 4), } # Дедупликация по (Д,Ш,В) cleaned: List[Dict[str, float]] = [] seen: set[tuple[float, float, float]] = set() for d in dims: norm = _normalize_one_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: continue seen.add(key) cleaned.append(norm) shipment["dimensions"] = cleaned # ========================================================= # 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 or w_cm is None or h_cm is None: 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 if l_cm <= 0 or w_cm <= 0 or h_cm <= 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_brand = _norm_info_str(a.get("brand_name")) b_brand = _norm_info_str(b.get("brand_name")) if a_brand and b_brand and a_brand != b_brand: score += 2 a_hs = _norm_info_str(a.get("hs_code")) b_hs = _norm_info_str(b.get("hs_code")) if a_hs and b_hs and a_hs != b_hs: score += 2 a_cd = _norm_info_str(a.get("cargo_description")) b_cd = _norm_info_str(b.get("cargo_description")) if a_cd and b_cd and a_cd != b_cd: score += 2 # Готовность к отгрузке может отличаться для разных партий a_rd = _norm_info_str(a.get("cargo_ready_date")) b_rd = _norm_info_str(b.get("cargo_ready_date")) if a_rd and b_rd and a_rd != b_rd: score += 1 # Ценность может отличаться для разных партий/позиции a_val = _norm_info_str(a.get("cargo_value")) b_val = _norm_info_str(b.get("cargo_value")) if a_val and b_val and a_val != b_val: score += 1 if _strong_invoice_value_mismatch( str(a.get("cargo_value") or ""), str(b.get("cargo_value") or "") ): score += 4 for key in ("msds_required", "dgm_report_required", "brand_authorization_letter"): av = _bool_or_none(a.get(key)) bv = _bool_or_none(b.get(key)) if av is not None and bv is not None and av != bv: score += 2 a_dg = _any_true_dg(a.get("dangerous_goods")) b_dg = _any_true_dg(b.get("dangerous_goods")) if a_dg != b_dg: score += 1 return score clusters: List[Dict] = [] for s in shipments: if not isinstance(s, dict): continue placed = False for base in clusters: if _conflict_score(base, s) <= 1: self._merge_shipment_into(base, s) placed = True break if not placed: clusters.append(dict(s)) logger.info( "Clustered %d shipments (single email in session) -> %d", len(shipments), len(clusters), ) return clusters def _split_single_shipment_by_position_codes( self, shipment: Dict, source: Dict ) -> List[Dict]: """ Пытается разделить один shipment на несколько, если в тексте видны несколько разных кодов позиций (например, trocyps-26003, trocyps-26004). Делает это только когда нет явных числовых распределений, чтобы не было риска удвоить веса/габариты. """ if not isinstance(shipment, dict) or not isinstance(source, dict): return [shipment] head_txt = " ".join( str(x or "") for x in ( source.get("subject"), source.get("content"), ) ) if _is_tender_multi_origin_context(head_txt): logger.info( "Skip split by position codes: tender / multi-origin pickup context detected" ) return [shipment] # Числовые поля: если они заполнены, мы не знаем как корректно распределить по позициям. # Тогда безопаснее не разделять детерминированно. if shipment.get("total_weight_kg") not in [None, "", "не указан"]: return [shipment] if shipment.get("package_count") not in [None, "", "не указан"]: return [shipment] dims = shipment.get("dimensions") if isinstance(dims, list) and dims: return [shipment] # Объём/CBM тоже лучше не делить без правил. if shipment.get("total_volume_cbm") not in [None, "", "не указан"]: return [shipment] # Берём максимум текста, чтобы коды точно нашлись blob = self._collect_shipment_source_text(shipment, [source]) or "" # Основной кейс: trocyps-12345 / trocyps_12345 / trocyps 12345 codes = re.findall(r"\btrocyps[-_\s]?\d+\b", blob, flags=re.IGNORECASE) if not codes: # Общая эвристика: ABC-12345 (3+ буквы/цифры через дефис) codes = re.findall(r"\b[A-Za-z]{2,}[-_]\d{2,}\b", blob) # Нормализуем в нижний регистр и дедуплицируем norm_codes: List[str] = [] seen = set() for c in codes: cc = str(c).strip() if not cc: continue key = cc.lower() if key in seen: continue seen.add(key) norm_codes.append(cc) if len(norm_codes) < 2: return [shipment] # Разрезаем cargo_description (или blob) по появлениям кодов и берём сегменты. cargo_src = shipment.get("cargo_description") cargo_src_str = cargo_src if isinstance(cargo_src, str) else "" split_text = cargo_src_str.strip() or blob # Определяем индексы в split_text indices: List[tuple[int, str]] = [] lower_split = split_text.lower() for code in norm_codes: idx = lower_split.find(code.lower()) if idx >= 0: indices.append((idx, code)) if len(indices) < 2: return [shipment] indices.sort(key=lambda x: x[0]) segments: List[str] = [] for i, (start, code) in enumerate(indices): end = indices[i + 1][0] if i + 1 < len(indices) else len(split_text) seg = split_text[start:end].strip() segments.append(seg if seg else code) out: List[Dict] = [] for i, seg in enumerate(segments): new_s = dict(shipment) # Положим в описание сегмент вокруг конкретного кода позиции # Верхний предел только против аномально больших строк (см. RAG_MAX_SEGMENT_CHARS) _max_seg = _max_cargo_segment_chars() new_s["cargo_description"] = seg if _max_seg <= 0 else seg[:_max_seg] out.append(new_s) return out def _merge_shipments_same_destination( self, shipments: List[Dict], context_text: str = "" ) -> List[Dict]: """ Объединяет перевозки с одинаковым адресом выгрузки в одну запись. Поля объединяются консервативно. Если по «смысловым якорям» (бренд, HS, тип, нужные документы) видны различающиеся партии/заказа, shipments разделяются на несколько записей. Для тендеров с несколькими адресами забора и одной выгрузкой — мягче порог склейки. """ if not isinstance(shipments, list) or len(shipments) <= 1: return shipments tender_relaxed = _is_tender_multi_origin_context(context_text or "") merge_threshold = 4 if tender_relaxed else 1 # 1) Сначала группируем по месту выгрузки. buckets: Dict[str, List[Dict]] = {} passthrough: List[Dict] = [] for s in shipments: if not isinstance(s, dict): continue dest = (s.get("delivery_address") or "").strip() dest_key = self._normalize_text_key(dest) if not dest_key: passthrough.append(s) continue buckets.setdefault(dest_key, []).append(s) # 2) Объединяем все перевозки в группе с одним и тем же нормализованным адресом выгрузки # (две и больше записей). Сначала строим кластеры внутри группы по delivery_address. # Правило: если есть явные конфликты по бренду/HS/типу/документам — не сливаем. def _norm_info_str(v: Any) -> str: if not isinstance(v, str): return "" s = v.strip() if not s: return "" low = s.lower() # Пустые/служебные значения, которые нельзя считать «доказательством». if low in {"не указано", "информация отсутствует", "нет", "unknown", "n/a", "na", "none"}: return "" return s def _bool_or_none(v: Any) -> Optional[bool]: if v is True: return True if v is False: return False return None def _any_true_dg(dg: Any) -> bool: if not isinstance(dg, dict): return False for k in ("batteries", "gases", "liquids", "dry_ice"): if dg.get(k) is True: return True return False def _conflict_score(a: Dict, b: Dict) -> int: score = 0 a_brand = _norm_info_str(a.get("brand_name")) b_brand = _norm_info_str(b.get("brand_name")) if a_brand and b_brand and a_brand != b_brand: score += 2 a_hs = _norm_info_str(a.get("hs_code")) b_hs = _norm_info_str(b.get("hs_code")) if a_hs and b_hs and a_hs != b_hs: score += 2 # Тип перевозки (если он распознан у обоих) a_st = _norm_info_str(a.get("shipping_type")) b_st = _norm_info_str(b.get("shipping_type")) if a_st and b_st and a_st != b_st: score += 1 # Описание груза (часто соответствует разным позициям/партиям) a_cd = _norm_info_str(a.get("cargo_description")) b_cd = _norm_info_str(b.get("cargo_description")) if a_cd and b_cd and a_cd != b_cd: score += 2 if tender_relaxed and ( ("стм" in a_cd.lower() and "стм" in b_cd.lower()) or (a_cd.lower() in b_cd.lower() or b_cd.lower() in a_cd.lower()) ): score -= 1 # Готовность к отгрузке может отличаться для разных партий a_rd = _norm_info_str(a.get("cargo_ready_date")) b_rd = _norm_info_str(b.get("cargo_ready_date")) if a_rd and b_rd and a_rd != b_rd: if not tender_relaxed: score += 1 # Ценность (если отличаемая в письме) a_val = _norm_info_str(a.get("cargo_value")) b_val = _norm_info_str(b.get("cargo_value")) if a_val and b_val and a_val != b_val: score += 1 if _strong_invoice_value_mismatch( str(a.get("cargo_value") or ""), str(b.get("cargo_value") or "") ): score += 4 # Документы/требования: конфликт если одно явно True, другое явно False for key in ("msds_required", "dgm_report_required", "brand_authorization_letter"): av = _bool_or_none(a.get(key)) bv = _bool_or_none(b.get(key)) if av is not None and bv is not None and av != bv: score += 2 # Опасный груз: конфликт если у одного явно опасность категорий, у другого нет a_dg = _any_true_dg(a.get("dangerous_goods")) b_dg = _any_true_dg(b.get("dangerous_goods")) if a_dg != b_dg: score += 1 return score merged: List[Dict] = [] for dest_key, group in buckets.items(): if len(group) < 2: passthrough.extend(group) continue clusters: List[Dict] = [] for s in group: if not isinstance(s, dict): continue placed = False for base in clusters: # Порог: обычно строгий; для тендера с N точками забора — выше (разные даты — норма). if _conflict_score(base, s) <= merge_threshold: self._merge_shipment_into(base, s) placed = True break if not placed: clusters.append(dict(s)) merged.extend(clusters) merged = merged + passthrough logger.info( "Merged shipments by destination (clustered): %s -> %s (tender_relaxed=%s, thr=%s)", len(shipments), len(merged), tender_relaxed, merge_threshold, ) return merged def _extract_container_mentions(self, text: str) -> List[str]: if not isinstance(text, str) or not text.strip(): return [] t = text.lower().replace("'", "").replace('"', "") t = t.replace("ф", "f").replace("х", "x") results: List[str] = [] def _normalize_type(size: str, kind: str) -> str: k = (kind or "").lower().strip() kind_map = { "hc": "HC", "hq": "HC", "dc": "DC", "gp": "DC", "rf": "RF", "rh": "RF", "reefer": "RF", "ref": "RF", "реф": "RF", "рефр": "RF", "ot": "OT", "fr": "FR", } k_norm = kind_map.get(k, "") return f"{size}{k_norm}" if k_norm else size # Варианты с количеством: 2*40HC, 3x20DC, 2 40hc p_count = re.compile(r"(? Optional[str]: if not isinstance(shipment, dict): return None email_ids = set(shipment.get("ID_emails") or []) if not email_ids: return None text_chunks: List[str] = [] for src in sources or []: if src.get("id") not in email_ids: continue for key in ["subject", "content"]: val = src.get(key) if isinstance(val, str) and val.strip(): text_chunks.append(val) for att in src.get("attachments", []) or []: att_text = att.get("text") if isinstance(att_text, str) and att_text.strip(): text_chunks.append(att_text) if not text_chunks: return None mentions = self._extract_container_mentions("\n".join(text_chunks)) return ", ".join(mentions) if mentions else None def _collect_shipment_source_text( self, shipment: Dict, sources: List[Dict], *, include_email: bool = True, include_attachments: bool = True, ) -> str: """Текст по перевозке (по ID_emails) с управлением источниками.""" if not isinstance(shipment, dict): return "" email_ids = set(shipment.get("ID_emails") or []) parts: List[str] = [] for src in sources or []: if src.get("id") not in email_ids: continue if include_email: for key in ("subject", "content"): val = src.get(key) if isinstance(val, str) and val.strip(): parts.append(val) if include_attachments: for att in src.get("attachments", []) or []: att_text = att.get("text") if isinstance(att_text, str) and att_text.strip(): parts.append(att_text) return "\n".join(parts) @staticmethod def _text_mentions_china_context(text: str) -> bool: if not isinstance(text, str) or not text.strip(): return False t = text.lower() markers = ( "китай", "china", "кнр", "chinese", "mainland china", "материковый китай", "shenzhen", "shanghai", "guangzhou", "ningbo", "yiwu", "qingdao", "xiamen", "foshan", "tianjin", "beijing", "hong kong", "гонконг", "guangdong", "zhejiang", "jiangsu", "fujian", "义乌", "深圳", "广州", "宁波", ) return any(m in t for m in markers) def _infer_brand_and_authorization_from_sources(self, shipment: Dict, sources: List[Dict]) -> None: """Достаёт название бренда из текста писем и выставляет необходимость авторизационного письма.""" if not isinstance(shipment, dict): return email_blob = self._collect_shipment_source_text( shipment, sources, include_email=True, include_attachments=False ) att_blob = self._collect_shipment_source_text( shipment, sources, include_email=False, include_attachments=True ) blob = email_blob if email_blob.strip() else att_blob if not isinstance(blob, str) or not blob.strip(): return low = blob.lower() bn = shipment.get("brand_name") if not (isinstance(bn, str) and bn.strip()): brand_patterns = [ r"(?:бренд|brand|trademark|\bтм\b)\s*[::]\s*([^\n\r,;]{1,2000})", r"торгов(?:ая|ой)\s+марка\s*[::]\s*([^\n\r,;]{1,2000})", r"марк[аи]\s+товара\s*[::]\s*([^\n\r,;]{1,2000})", r"(?:производител[ья]|vendor|manufacturer|oem)\s*[::]\s*([^\n\r,;]{1,2000})", r"logo\s*[::]\s*([^\n\r,;]{1,2000})", ] for pat in brand_patterns: m = re.search(pat, blob, flags=re.IGNORECASE) if not m: continue raw_name = m.group(1).strip() raw_name = raw_name.strip('«»"\'•· \t').strip() raw_name = re.split(r"\s{2,}|\t", raw_name)[0].strip() if not raw_name or len(raw_name) > 4000: continue bad = {"n/a", "na", "none", "нет", "no", "tbd", "—", "-", "same as above"} if raw_name.lower() in bad: continue shipment["brand_name"] = raw_name[:4000] break auth_patterns = ( r"авторизационн\w*\s+письм", r"authorization\s+letter", r"authorisation\s+letter", r"brand\s+authori[sz]ation", r"loa\b|\bletter\s+of\s+authorization\b", r"письм\w*\s+(?:от\s+)?бренд", r"разрешени\w*\s+(?:от\s+)?бренд", r"бренд\w*\s+в\s+таможенн", r"таможенн\w*\s+систем\w*\s+китая", r"китайск\w*\s+таможен", r"регистрац\w*\s+бренд", r"trademark\s+registration", r"\b1688\b|1688\.com|tmall|taobao", ) if any(re.search(p, low) for p in auth_patterns): shipment["brand_authorization_letter"] = True loc_blob = " ".join( str(x) for x in ( shipment.get("pickup_address"), shipment.get("loading_port"), shipment.get("delivery_address"), shipment.get("cargo_description"), blob, ) if x ) if self._text_mentions_china_context(loc_blob): b = shipment.get("brand_name") if isinstance(b, str) and b.strip(): shipment["brand_authorization_letter"] = True # Текстовая сводка по пункту авторизационного письма (для вывода в отчёте/шаблонах). lines_email = [ln.strip() for ln in email_blob.splitlines() if isinstance(ln, str) and ln.strip()] lines_att = [ln.strip() for ln in att_blob.splitlines() if isinstance(ln, str) and ln.strip()] auth_lines: List[str] = [] auth_need_re = re.compile( r"авторизационн\w*\s+письм|authorization\s+letter|authorisation\s+letter|" r"letter\s+of\s+authorization|\bloa\b|регистрац\w*\s+бренд|trademark\s+registration|" r"бренд\w*\s+в\s+таможенн", re.IGNORECASE, ) auth_not_needed_re = re.compile( r"авторизационн\w*\s+письм\w*\s+не\s+(?:нужн|треб)|" r"authorization\s+letter\s+(?:is\s+)?not\s+required|no\s+authorization\s+letter", re.IGNORECASE, ) for ln in lines_email: if auth_need_re.search(ln): auth_lines.append(re.sub(r"\s+", " ", ln)) # Если есть явное "не требуется", это тоже важно показать. for ln in lines_email: if auth_not_needed_re.search(ln): clean = re.sub(r"\s+", " ", ln) if clean not in auth_lines: auth_lines.append(clean) # Только если в письме ничего не найдено — добираем релевантные строки из вложений. if not auth_lines: for ln in lines_att: if auth_need_re.search(ln) or auth_not_needed_re.search(ln): auth_lines.append("[вложение] " + re.sub(r"\s+", " ", ln)) docs = shipment.get("documents_found", {}) or {} files = docs.get("brand_authorization") or [] if isinstance(files, list) and files: fnames = [str(x.get("filename") or "").strip() for x in files if isinstance(x, dict)] fnames = [f for f in fnames if f] if fnames: auth_lines.append("Найдены вложения: " + ", ".join(dict.fromkeys(fnames))) if auth_lines: shipment["brand_authorization_info"] = " | ".join(dict.fromkeys(auth_lines))[:3000] else: 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 "" 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 # Явное "не опасный" / non-DG — проставляем False по пустым категориям. explicit_non_dg = bool( (low_email and re.search( r"\bnon[\s\-]?dg\b|not\s+dangerous|not\s+hazardous|без\s+опасн\w+\s+груз|" r"не\s+опасн\w+\s+груз|опасн\w+\s+груз\w*\s+нет|не\s+hazmat", low_email, re.IGNORECASE, )) or (low_att and re.search( r"\bnon[\s\-]?dg\b|not\s+dangerous|not\s+hazardous|без\s+опасн\w+\s+груз|" r"не\s+опасн\w+\s+груз|опасн\w+\s+груз\w*\s+нет|не\s+hazmat", low_att, re.IGNORECASE, )) ) if explicit_non_dg: for k in ("batteries", "gases", "liquids", "dry_ice"): if dg.get(k) is None: dg[k] = False any_true = any(dg.get(k) is True for k in ("batteries", "gases", "liquids", "dry_ice")) if any_true: # Не затираем уже существующий note, но и не навязываем. return # Обобщенный индикатор опасности, если категория не распознана. generic_dg = bool( (low_email and re.search( r"\bdg\b|dangerous\s+goods|hazardous\s+(?:goods|material)|hazmat|imdg|" r"опасн\w+\s+груз|класс\s+опасност|class\s+[1-9]\b|" r"\bun\s*\d{4}\b|ун\s*\d{4}", low_email, re.IGNORECASE, )) or (low_att and re.search( r"\bdg\b|dangerous\s+goods|hazardous\s+(?:goods|material)|hazmat|imdg|" r"опасн\w+\s+груз|класс\s+опасност|class\s+[1-9]\b|" r"\bun\s*\d{4}\b|ун\s*\d{4}", low_att, re.IGNORECASE, )) ) if generic_dg: # По требованию UI: в 9-м пункте показываем только факт Да/Нет/Нет информации. # Если есть лишь общий намёк на DG без конкретной категории, не подставляем текстовый note. shipment.pop("dangerous_goods_note", None) def _extract_quantities_from_text(self, raw_text: str) -> Dict[str, Any]: """ Детерминированное извлечение тройки «ctn/ctns + kg + cbm». Это нужно, чтобы не брать первое попавшееся значение (иногда в письме несколько частей). """ if not isinstance(raw_text, str) or not raw_text.strip(): return {} def _to_float(v: str) -> Optional[float]: if not isinstance(v, str): return None s = v.strip().replace(" ", "") s = s.replace(",", ".") try: return float(s) except Exception: return None # Триплет в одном месте строки: "... 57ctns ... 673kgs ... 5.07cbm" triplet_re = re.compile( r"(?P\d+(?:[.,]\d+)?)\s*(?:ctns?|carton(?:s)?|boxes?)\b" r".{0,80}?" r"(?P\d+(?:[.,]\d+)?)\s*(?:kgs?|kg|кг)\b" r".{0,80}?" r"(?P\d+(?:[.,]\d+)?)\s*(?:cbm|m3|m³|м3|м³)\b", re.IGNORECASE | re.DOTALL, ) triplets: List[tuple[float, float, float]] = [] for m in triplet_re.finditer(raw_text): ctn = _to_float(m.group("ctn")) kg = _to_float(m.group("kg")) cbm = _to_float(m.group("cbm")) if ctn is None or kg is None or cbm is None: continue triplets.append((ctn, kg, cbm)) # Если триплетов мало/нет — пробуем раздельные совпадения. ctn_re = re.compile( r"(?\d+(?:[.,]\d+)?)\s*(?:ctns?|carton(?:s)?|boxes?)\b", re.IGNORECASE, ) kg_re = re.compile( r"(?\d+(?:[.,]\d+)?)\s*(?:kgs?|kg|кг)\b", re.IGNORECASE, ) cbm_re = re.compile( r"(?\d+(?:[.,]\d+)?)\s*(?:cbm|m3|m³|м3|м³)\b", re.IGNORECASE, ) ctn_matches = [_to_float(m.group("ctn")) for m in ctn_re.finditer(raw_text)] kg_matches = [_to_float(m.group("kg")) for m in kg_re.finditer(raw_text)] cbm_matches = [_to_float(m.group("cbm")) for m in cbm_re.finditer(raw_text)] ctn_matches = [x for x in ctn_matches if x is not None] kg_matches = [x for x in kg_matches if x is not None] cbm_matches = [x for x in cbm_matches if x is not None] def _sum_or_none(vals: List[float]) -> Optional[float]: if not vals: return None return float(sum(vals)) triplet_sum = None if triplets: triplet_sum = { "package_count": _sum_or_none([t[0] for t in triplets]), "total_weight_kg": _sum_or_none([t[1] for t in triplets]), "total_volume_cbm": _sum_or_none([t[2] for t in triplets]), "triplet_count": len(triplets), } separate_sum = { "package_count": _sum_or_none(ctn_matches), "total_weight_kg": _sum_or_none(kg_matches), "total_volume_cbm": _sum_or_none(cbm_matches), "ctn_count": len(ctn_matches), "kg_count": len(kg_matches), "cbm_count": len(cbm_matches), } return { "triplet_sum": triplet_sum, "separate_sum": separate_sum, } def _shipment_weight_hints(self, shipment: Dict) -> List[str]: """Ключевые токены для привязки строк веса к конкретной перевозке.""" if not isinstance(shipment, dict): return [] blob = " ".join( str(x or "") for x in ( shipment.get("cargo_description"), shipment.get("pickup_address"), shipment.get("delivery_address"), shipment.get("container_type"), shipment.get("hs_code"), shipment.get("cargo_value"), ) ) out: List[str] = [] # Явные коды партий/заказов. for pat in ( r"\btrocyps[-_\s]?\d+\b", r"\b[A-Za-z]{2,}[-_]\d{2,}\b", r"\b[A-Za-z0-9]{3,}[-_][A-Za-z0-9]{2,}\b", ): for m in re.findall(pat, blob, flags=re.IGNORECASE): tok = str(m).strip().lower() if tok and tok not in out: out.append(tok) # Общие слова тоже берём, но ограниченно. stop = { "cargo", "weight", "gross", "net", "total", "kg", "kgs", "cbm", "ctn", "груз", "вес", "брутто", "нетто", "итого", "всего", "адрес", "доставка", } for w in re.findall(r"[a-zA-Zа-яА-ЯёЁ0-9]{4,}", blob.lower()): if w in stop: continue if w not in out: out.append(w) if len(out) >= 24: break return out def _extract_preferred_weight_kg(self, raw_text: str, shipment: Dict) -> Dict[str, Any]: """ Извлекает вес с приоритетом: 1) gross/brutto 2) нейтральный вес (без указания net/gross) 3) net И старается оставить только строки, относящиеся к текущей перевозке. """ if not isinstance(raw_text, str) or not raw_text.strip(): return {"value": None, "count": 0, "priority": "none", "context_matched": False} hints = self._shipment_weight_hints(shipment) lines = [ln for ln in raw_text.splitlines() if isinstance(ln, str) and ln.strip()] if not lines: return {"value": None, "count": 0, "priority": "none", "context_matched": False} num_unit_re = re.compile( r"(?P\d+(?:[.,]\d+)?)\s*(?Pkgs?|kg|кг|тонн(?:а|ы)?|тонн|tons?|ton|t)\b", re.IGNORECASE, ) gross_re = re.compile(r"\bgross\b|\bg\.?\s*w\.?\b|брутто|brutto", 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() 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 # Внутри bucket предпочитаем строки с "итого/total". with_total = [c for c in bucket if c["is_total"]] chosen = with_total if with_total else bucket seen: set[tuple[float, str]] = set() vals: List[float] = [] for c in chosen: key = (round(float(c["value"]), 3), str(c["line"]).lower()) if key in seen: continue seen.add(key) vals.append(float(c["value"])) if not vals: continue return { "value": float(sum(vals)), "count": len(vals), "priority": bucket_name, "context_matched": context_matched, } return {"value": None, "count": 0, "priority": "none", "context_matched": context_matched} def _line_looks_vehicle_size_context(self, line: str) -> bool: """Строка про габариты транспорта/машины — не смешивать с габаритами груза.""" ll = line.lower() keys = ( "габарит машин", "габариты машин", "габарит тс", "габариты тс", "габарит транспорт", "габариты транспорт", "размер фуры", "длина фуры", "длина тента", "габарит кузов", "габариты кузов", "габарит прицеп", "габариты прицеп", "габарит полуприцеп", "vehicle dimension", "truck dimension", "dimensions of truck", "размер автопоезд", "высота тс", "длина тс", "ширина тс", ) return any(k in ll for k in keys) def _extract_dimensions_from_text(self, raw_text: str) -> List[Dict[str, float]]: """ Извлекает все уникальные Д×Ш×В (длина/ширина/высота) из таблиц/строк. Конвертирует мм->см по эвристике (если значения выглядят как мм). """ if not isinstance(raw_text, str) or not raw_text.strip(): return [] def _to_float(v: str) -> Optional[float]: if not isinstance(v, str): return None s = v.strip().replace(" ", "") s = s.replace(",", ".") try: return float(s) except Exception: return None # Тройка чисел через x/×/X; опционально мм/см сразу после третьего числа triple_re = re.compile( r"(?P\d+(?:[.,]\d+)?)\s*(?:x|×|X)\s*(?P\d+(?:[.,]\d+)?)\s*(?:x|×|X)\s*(?P\d+(?:[.,]\d+)?)" r"\s*(?Pмм|mm|см|cm)?(?!\d)", re.IGNORECASE, ) lines = raw_text.splitlines() def _looks_like_carton(line: str) -> bool: ll = line.lower() return any(k in ll for k in ["carton size", "carton", "габарит", "размер", "size", "size/ctn"]) candidates = [ln for ln in lines if _looks_like_carton(ln)] if not candidates: candidates = lines dims: List[Dict[str, float]] = [] seen: set[tuple[float, float, float]] = set() for ln in candidates: if self._line_looks_vehicle_size_context(ln): continue line_unit = _line_hint_dimension_unit(ln) for m in triple_re.finditer(ln): a = _to_float(m.group("a")) b = _to_float(m.group("b")) c = _to_float(m.group("c")) if a is None or b is None or c is None: continue u_raw = (m.group("u") or "").strip().lower() explicit: Optional[str] = None if u_raw in ("мм", "mm"): explicit = "mm" elif u_raw in ("см", "cm"): explicit = "cm" elif u_raw in ("m", "м"): explicit = "m" if explicit is None: explicit = line_unit l_cm, w_cm, h_cm = infer_cargo_triple_raw_to_cm(a, b, c, explicit_unit=explicit) # Плаузибл-диапазон для габаритов единичной тары (в см). if not (1 <= l_cm <= 200 and 1 <= w_cm <= 200 and 1 <= h_cm <= 200): continue l_cm = round(l_cm, 4) w_cm = round(w_cm, 4) h_cm = round(h_cm, 4) key = (round(l_cm, 1), round(w_cm, 1), round(h_cm, 1)) if key in seen: continue seen.add(key) dims.append({"length_cm": l_cm, "width_cm": w_cm, "height_cm": h_cm}) return dims def _extract_vehicle_dimensions_from_text(self, raw_text: str) -> List[Dict[str, float]]: """Тройки размеров только из строк с контекстом ТС (фура, кузов, машина).""" if not isinstance(raw_text, str) or not raw_text.strip(): return [] def _to_float(v: str) -> Optional[float]: if not isinstance(v, str): return None s = v.strip().replace(" ", "").replace(",", ".") try: return float(s) except Exception: return None triple_re = re.compile( r"(?P\d+(?:[.,]\d+)?)\s*(?:x|×|X)\s*(?P\d+(?:[.,]\d+)?)\s*(?:x|×|X)\s*(?P\d+(?:[.,]\d+)?)(?!\d)", re.IGNORECASE, ) lines = raw_text.splitlines() out: List[Dict[str, float]] = [] seen: set[tuple[float, float, float]] = set() for ln in lines: if not self._line_looks_vehicle_size_context(ln): continue ll = ln.lower() meters_hint = bool(re.search(r"\bм\b|meter|metre|\bm\s", ll)) for m in triple_re.finditer(ln): a = _to_float(m.group("a")) b = _to_float(m.group("b")) c = _to_float(m.group("c")) if a is None or b is None or c is None: continue vals = [a, b, c] if meters_hint or (max(vals) <= 30 and min(vals) >= 0.25): vals = [v * 100.0 for v in vals] else: unit_mm = max(vals) > 250 if unit_mm: vals = [v / 10.0 for v in vals] l_cm, w_cm, h_cm = vals[0], vals[1], vals[2] if not (50 <= l_cm <= 3000 and 50 <= w_cm <= 400 and 50 <= h_cm <= 500): continue l_cm = round(l_cm, 4) w_cm = round(w_cm, 4) h_cm = round(h_cm, 4) key = (round(l_cm, 1), round(w_cm, 1), round(h_cm, 1)) if key in seen: continue seen.add(key) out.append({"length_cm": l_cm, "width_cm": w_cm, "height_cm": h_cm}) return out def _postprocess_quantities_and_dimensions( self, shipment: Dict, sources: List[Dict], ) -> None: """ Детерминированное усиление: - пересчитать package_count/weight/volume по множественным упоминаниям (не брать первое) - расширить dimensions всеми вариантами размеров """ if not isinstance(shipment, dict): return raw_text = self._collect_shipment_source_text(shipment, sources) if not raw_text.strip(): return quantities = self._extract_quantities_from_text(raw_text) triplet_sum = quantities.get("triplet_sum") or None separate_sum = quantities.get("separate_sum") or {} preferred_weight = self._extract_preferred_weight_kg(raw_text, shipment) total_shipments = getattr(self, "_postprocess_total_shipments", None) single_shipment_mode = isinstance(total_shipments, int) and total_shipments == 1 def _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")) if 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) Габариты: расширяем dimensions всеми извлеченными вариантами. dims = self._extract_dimensions_from_text(raw_text) if dims: existing_dims = shipment.get("dimensions") if not isinstance(existing_dims, list): existing_dims = [] merged = existing_dims + dims # Дедупликация (грубая) перед normalize_shipment. seen_key: set[tuple[float, float, float]] = set() out: List[Dict[str, float]] = [] for d in merged: 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: # нормализация размеров (и конвертация *_mm) произойдёт в normalize_shipment out.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.append(d) continue key = (round(l_f, 1), round(w_f, 1), round(h_f, 1)) if key in seen_key: continue seen_key.add(key) out.append({"length_cm": l_f, "width_cm": w_f, "height_cm": h_f}) shipment["dimensions"] = out vdims = self._extract_vehicle_dimensions_from_text(raw_text) if vdims: existing_v = shipment.get("vehicle_dimensions") if not isinstance(existing_v, list): existing_v = [] merged_v = existing_v + vdims seen_v: set[tuple[float, float, float]] = set() out_v: List[Dict[str, float]] = [] for d in merged_v: if not isinstance(d, dict): continue l = d.get("length_cm") w = d.get("width_cm") h = d.get("height_cm") if l is None or w is None or h is None: out_v.append(d) continue try: l_f = float(str(l).replace(" ", "").replace(",", ".")) w_f = float(str(w).replace(" ", "").replace(",", ".")) h_f = float(str(h).replace(" ", "").replace(",", ".")) except Exception: out_v.append(d) continue key = (round(l_f, 1), round(w_f, 1), round(h_f, 1)) if key in seen_v: continue seen_v.add(key) out_v.append({"length_cm": l_f, "width_cm": w_f, "height_cm": h_f}) shipment["vehicle_dimensions"] = out_v def _enrich_operator_document_services(self, shipment: Dict, sources: List[Dict]) -> None: """ Если в письме явно указано, что документ не предоставляет клиент/контрагент, добавляем в additional_services строку о подготовке документа силами оператора. """ if not isinstance(shipment, dict): return raw_text = self._collect_shipment_source_text(shipment, sources) if not raw_text.strip(): return text_l = raw_text.lower() docs = shipment.get("documents_found", {}) or {} required_missing = { "msds": shipment.get("msds_required") is True and not (docs.get("msds") or []), "dgm": shipment.get("dgm_report_required") is True and not (docs.get("dgm") or []), "brand_authorization": shipment.get("brand_authorization_letter") is True and not (docs.get("brand_authorization") or []), } # Отдельно учитываем формулировки "документ запрошен", это НЕ "не предоставят". msds_requested = bool(re.search( r"\bmsds\b.{0,24}\b(запрош\w*|requested|request)\b|\b(запрош\w*|requested|request)\b.{0,24}\bmsds\b", text_l, re.IGNORECASE, )) actors = r"(клиент|заказчик|покупател\w*|контрагент\w*|поставщик\w*|отправител\w*|грузоотправител\w*)" neg_verbs = r"(не\s+(?:предостав\w*|высыла\w*|пришл\w*|прикладыва\w*|имеет\w*|в\s+наличии))" doc_words = { "msds": r"(?:\bmsds\b|паспорт\w*\s+безопасност\w*|material\s+safety\s+data\s+sheet|safety\s+data\s+sheet)", "dgm": r"(?:\bdgm\b|dangerous\s+goods|декларац\w*\s+на\s+опасн|imdg)", "brand_authorization": r"(?:авторизацион\w*\s+письм\w*|authorization\s+letter|authorisation\s+letter|письм\w*\s+бренд)", } def _doc_not_provided(doc_key: str) -> bool: dpat = doc_words[doc_key] # "поставщик не предоставляет <документ>" или "<документ> не предоставляет(ся)" p1 = rf"{actors}\b.{{0,45}}?{neg_verbs}\b.{{0,45}}?{dpat}" p2 = rf"{dpat}.{{0,45}}?{neg_verbs}" return bool(re.search(p1, text_l, re.IGNORECASE) or re.search(p2, text_l, re.IGNORECASE)) doc_not_provided = { "msds": _doc_not_provided("msds"), "dgm": _doc_not_provided("dgm"), "brand_authorization": _doc_not_provided("brand_authorization"), } # Защита от ложного срабатывания: "MSDS запрошен" без отрицания. if msds_requested and not doc_not_provided["msds"]: required_missing["msds"] = False # Добавляем пункты в additional_services только если одновременно: # (1) по данным перевозки этот документ нужен и не приложен, и # (2) в тексте писем явно сказано, что КОНКРЕТНО ЭТОТ документ не предоставляет клиент/поставщик. # Раньше использовалась общая фраза «документы не предоставляются» — из‑за неё подтягивались # и MSDS, и DGM, и письмо бренда разом; отдельно DGM не должен включать строку про MSDS. additions: List[str] = [] if required_missing["msds"] and doc_not_provided["msds"]: additions.append( "Получение/подготовка MSDS силами оператора (по письму документ не предоставляется клиентом/поставщиком)" ) if required_missing["dgm"] and doc_not_provided["dgm"]: additions.append( "Подготовка DGM / декларации на опасный груз силами оператора (по письму документ не от клиента/поставщика)" ) if required_missing["brand_authorization"] and doc_not_provided["brand_authorization"]: additions.append( "Получение авторизационного письма бренда силами оператора (по письму документ не от клиента/поставщика)" ) if not additions: return extras = shipment.get("additional_services") if not isinstance(extras, list): extras = [] seen = {str(x).strip().lower() for x in extras if str(x).strip()} for line in additions: key = line.strip().lower() if key not in seen: extras.append(line) seen.add(key) shipment["additional_services"] = extras def get_attachment(self, session_id: str, email_index: int, attachment_index: int) -> Optional[Dict]: """Возвращает информацию о вложении включая оригинальное содержимое""" if session_id not in self.sessions: return None emails = self.sessions[session_id] if email_index < 0 or email_index >= len(emails): return None attachments = emails[email_index].get("attachments", []) if attachment_index < 0 or attachment_index >= len(attachments): return None return attachments[attachment_index] def _extract_email_content(self, email: Dict) -> tuple: parts = [ f"От: {email.get('senderName', '')} ({email.get('sender', '')})", f"Тема: {email.get('subject', '')}", f"Кому: {email.get('to', '')}", f"Дата: {email.get('receivedTime', '')}", f"Содержание: {email.get('body', '')}" ] attachments_list = [] attachments = email.get('attachments', []) if attachments: parts.append("Вложения:") for att in attachments: filename = att.get('filename', 'unknown') size = att.get('size', 0) content_b64 = att.get('content') att_text = "" if content_b64: try: file_content = base64.b64decode(content_b64) extracted_text = self.doc_processor.extract_text(file_content, filename) att_text = extracted_text if isinstance(extracted_text, str) else "" if att_text.strip(): parts.append(f"- {filename} ({size} байт):\n{att_text}") else: parts.append( f"- {filename} ({size} байт): (извлекаемого текста нет — например изображение; " f"учитывайте имя файла и тело письма)" ) except Exception as e: logger.error(f"Failed to extract text from attachment {filename}: {e}") parts.append(f"- {filename} ({size} байт) — ошибка извлечения текста") else: parts.append(f"- {filename} ({size} байт) — содержимое не передано") attachments_list.append( {"filename": filename, "text": att_text, "size": size, "content_base64": content_b64} ) return "\n".join(parts), attachments_list def _two_pass_preflight_enabled(self) -> bool: """Второй проход: RAG_TWO_PASS_LLM=0|false|off отключает.""" raw = os.getenv("RAG_TWO_PASS_LLM", "1").strip().lower() if raw in ("", "1", "true", "yes", "on", "enable", "enabled"): return True if raw in ("0", "false", "no", "off", "disable", "disabled"): return False return True def _run_preflight_classification( self, context_text: str, query_text: str, type_names: List[str], ) -> Optional[Dict[str, Any]]: """ Проход 1: короткая классификация (режим FCL/LCL, модальности, подсказка shipping_type). Результат вставляется во второй проход как orientирующая подсказка, не как истина. """ if not (context_text and str(context_text).strip()) or context_text.strip() == "Нет писем для анализа.": return None max_ch = int(os.getenv("RAG_PREFLIGHT_MAX_CHARS", "48000")) ctx = context_text if len(context_text) <= max_ch else ( context_text[:max_ch] + f"\n... [обрезано для preflight, RAG_PREFLIGHT_MAX_CHARS={max_ch}]" ) model = os.getenv("RAG_PREFLIGHT_MODEL", "").strip() or "card_generation" 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 должна быть мультимодальной (а не чисто морской/жд).""" names_lines = "\n".join(str(n) for n in type_names if n) user_content = ( "Допустимые значения shipping_type_suggestion (ровно одна строка из списка ниже):\n" + names_lines + "\n\n--- ТЕКСТ ПИСЕМ ---\n" + 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, ) raw = (response.choices[0].message.content or "").strip() parsed = self._parse_json_response(raw) if not isinstance(parsed, dict): return None # нормализация имени типа sug = parsed.get("shipping_type_suggestion") if isinstance(sug, str) and sug.strip() and type_names: if sug.strip() not in type_names: # мягкое сопоставление без переопределения списка logger.warning( "Preflight shipping_type_suggestion %r не из списка — обнуляем", sug.strip(), ) parsed["shipping_type_suggestion"] = "" return parsed except Exception as e: logger.warning("Preflight classification failed: %s", e, exc_info=True) return None def _select_shipping_types_for_prompt( self, context_text: str, query_text: str, preflight: Optional[Dict[str, Any]], ) -> List[Dict]: """ Уменьшает блок типов перевозок в промпте: оставляет top-K по совпадению keywords с текстом (письма + запрос), плюс тип из preflight.shipping_type_suggestion если есть. Если совпадений нет или запрошены все модальности — возвращает полный список. """ k = _shipping_types_prompt_k() all_types = [st for st in self.shipping_types if isinstance(st, dict)] if k <= 0 or not all_types: return all_types if preflight and preflight.get("indicative_all_modes") is True: logger.info("RAG_SHIPPING_TYPES_PROMPT_K: indicative_all_modes — все типы в промпте") return all_types text = f"{context_text}\n{query_text or ''}".lower() scored: List[tuple[int, str, Dict]] = [] for st in all_types: name = str(st.get("name") or "") score = 0 for kw in st.get("keywords") or []: if not isinstance(kw, str): continue kws = kw.strip().lower() if len(kws) >= 2 and kws in text: score += 1 scored.append((score, name, st)) scored.sort(key=lambda x: (-x[0], x[1])) if not scored or scored[0][0] == 0: logger.info( "RAG_SHIPPING_TYPES_PROMPT_K=%d: нет совпадений keywords — все %d типов в промпте", k, len(all_types), ) return all_types selected: List[Dict] = [] picked: set[str] = set() sug = (preflight or {}).get("shipping_type_suggestion") if isinstance(sug, str) and sug.strip(): for sc, nm, st in scored: if nm == sug.strip(): selected.append(st) picked.add(nm) break for sc, nm, st in scored: if len(selected) >= k: break if nm in picked: continue selected.append(st) picked.add(nm) logger.info( "RAG_SHIPPING_TYPES_PROMPT_K=%d: в промпте %d из %d типов перевозок", k, len(selected), len(all_types), ) return selected def _collect_session_sources(self, session_id: Optional[str]) -> tuple[str, List[Dict]]: """Собирает context_text и sources для сессии (тот же формат, что и для LLM).""" if session_id and session_id in self.sessions: emails = self.sessions[session_id] logger.info(f"Session {session_id} has {len(emails)} emails") email_texts = [] sources: List[Dict] = [] for idx, email in enumerate(emails, 1): email_id = f"{session_id}_{idx}" email_texts.append(f"ID письма: {email_id}\n{email['content']}") sources.append({ "id": email_id, **email["metadata"], "content": email["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 compact = _truncate_structured_for_learning(sd, max_shipments=2, max_str=400) row = { "dedupe_hash": h, "context_preview": ctx[:20000], "context_words": sorted(_context_word_set(ctx[:12000]))[:500], "structured_data_compact": compact, "notes": (notes or "")[:2000], "session_id": session_id, "created_at": datetime.now().isoformat(timespec="seconds"), } rows.append(row) max_n = _learning_max_store() if len(rows) > max_n: rows = rows[-max_n:] self._write_learning_rows(rows) logger.info("Cargo learning: stored example (%d total)", len(rows)) return True def _maybe_auto_record_learning( self, session_id: Optional[str], context_text: str, result: Dict ) -> None: if not _learning_auto_enabled(): return sd = result.get("structured_data") if not isinstance(sd, dict): return if sd.get("parse_error") and not sd.get("shipments"): return try: self.record_cargo_learning( structured_data=sd, context_preview=context_text, session_id=session_id, notes="auto_after_analysis", ) except Exception as e: logger.warning("Cargo learning auto-record failed: %s", e) async def query_cargo_info(self, query_text: str, session_id: Optional[str] = None, top_k: int = 10) -> Dict: context_text, sources = self._collect_session_sources(session_id) cached = self._try_load_cached_report(session_id, query_text) if cached is not None: return cached if _email_thread_dedupe_enabled(): context_text = _dedupe_email_thread_paragraphs(context_text) max_ctx = _max_llm_context_chars() if max_ctx > 0 and len(context_text) > max_ctx: context_text = ( context_text[:max_ctx] + f"... [текст обрезан: RAG_MAX_CONTEXT_CHARS={max_ctx}]" ) type_names = [st.get("name") for st in self.shipping_types if st.get("name")] preflight: Optional[Dict[str, Any]] = None if self._two_pass_preflight_enabled(): preflight = self._run_preflight_classification( context_text, query_text or "", type_names ) if preflight: logger.info("Preflight LLM pass completed (keys=%s)", list(preflight.keys())) crit_max = _criteria_preview_chars() man_max = _mandatory_counterparty_chars() types_for_prompt = self._select_shipping_types_for_prompt( context_text, query_text or "", preflight ) shipping_types_info = [] for st in types_for_prompt: shipping_types_info.append({ "name": st.get("name"), "keywords": st.get("keywords", []) or [], "criteria_preview": _limit_prompt_field(st.get("criteria") or "", crit_max), "mandatory_counterparty_criteria": _limit_prompt_field( st.get("mandatory_counterparty_criteria") or "", man_max ), }) shipping_types_json_compact = _json_prompt_compact(shipping_types_info) preflight_block = "" if preflight: preflight_block = ( "\n\nПРЕДВАРИТЕЛЬНЫЙ ПРОХОД 1 — КЛАССИФИКАЦИЯ (отдельный короткий запрос к LLM до полного извлечения; ориентир, " "проверь по фактам в письмах ниже):\n" + _json_prompt_compact(preflight) + "\n\nИспользование: учти load_mode, shipment_type_if_container и shipping_type_suggestion " "при заполнении shipment_type и shipping_type. " "Если письма или вложения противоречат этому блоку — приоритет у текста писем/вложений. " "При confidence=low или пустом shipping_type_suggestion не заполняй shipping_type без явных признаков.\n" ) container_iso_ref_block = iso_reference_prompt_block() if container_iso_ref_block: container_iso_ref_block = "\n\n" + container_iso_ref_block + "\n" learning_fragment = self._learning_few_shot_system_fragment(context_text) system_prompt = f"""Ты - эксперт по логистике и грузоперевозкам. Твоя задача - проанализировать предоставленные письма и извлечь ВСЮ доступную информацию о грузоперевозках в строгом JSON-формате. Твоя задача — максимально точно извлечь данные из писем и вложений. {learning_fragment} КЛЮЧЕВЫЕ ПРАВИЛА: - Не додумывай факты. Если данных нет: null для чисел/неизвестных булевых, "" для строк, [] для списков. - Приоритет источников: основной текст письма > вложения > подписи/дисклеймеры (игнорировать). - Несколько shipments делай только при явном разделении на независимые партии/маршруты; иначе один shipment. - Не выводи рассуждения. PRE-FLIGHT: - Блок preflight ниже — только подсказка, не источник истины. - Если preflight противоречит письмам/вложениям, приоритет у писем/вложений. - Используй preflight только как ориентир для `shipping_type` и `shipment_type`, без дублирования его вывода. {preflight_block} ИСПОЛЬЗУЙ ТОЛЬКО ЭТИ ТИПЫ: {shipping_types_json_compact} ВЫБОР ТИПОВ ПЕРЕВОЗКИ: - `requested_shipping_type_names`: массив точных `name` из списка выше, которые явно запрошены/сравниваются для этой партии. - Один однозначный способ -> массив из одного имени. - Если клиент просит все/любой варианты -> перечисли все релевантные имена из списка. - Если явных признаков способа нет -> `requested_shipping_type_names` = [] и `shipping_type` = "". - `shipping_type` заполняй только когда в `requested_shipping_type_names` ровно один элемент, иначе "". - Мультимодальность море+ж/д ставь только для одной связанной цепочки маршрута; варианты "море ИЛИ ж/д" — это не мультимодальность. - FCL/LCL — контейнерная тема море/жд; FTL/LTL — автодорога. СОГЛАСОВАНИЕ ПОЛЕЙ: - `shipment_type`: только "FCL" | "LCL" | ""; при явном (FCL)/(LCL) в `shipping_type` согласуй значение. - `container_type` — для контейнерной море/жд темы; `vehicle_type` и `vehicle_dimensions` — для автодороги. - `shipping_options` заполняй только явно упомянутыми вариантами, без выдуманных цен/сроков. - `document_replacement_needed`: true/false только по явной формулировке, иначе null. - В `dangerous_goods` ставь true/false только при явном подтверждении/отрицании, иначе null. {container_iso_ref_block} ТРЕБУЕМЫЙ ФОРМАТ ОТВЕТА (ТОЛЬКО JSON): {{ "shipments": [ {{ "ID_emails": ["список ID писем, откуда взята информация"], "client_name": "название компании-клиента", "incoterms": "условия поставки Incoterms", "cargo_ready_date": "дата(ы) готовности груза к отгрузке: одна строка или массив строк; если для разных мест/партий указаны разные даты — перечисли все (позже отобразятся через запятую)", "pickup_address": "адрес забора груза", "cargo_value": "стоимость груза с валютой", "package_count": 10, "total_weight_kg": 150.5, "dimensions": [ {{"length_cm": 100, "width_cm": 80, "height_cm": 60, "dimension_unit": "cm"}} ], "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 }}, "stackable_with_others": true, "stackable_among_themselves": true, "msds_required": false, "batteries_packed_separately": false, "dgm_report_required": false, "brand_name": "название бренда", "brand_authorization_letter": true, "document_replacement_needed": null, "transshipment_with_third_country": false, "exporter_has_export_license": true, "additional_services": ["переупаковка", "маркировка"], "arrival_expediting_responsibility": "получатель", "delivery_address": "адрес доставки", "special_transport_requirements": "требования к транспорту (пропуск и т.п., не габариты ТС)", "shipment_type": "FCL или LCL или \"\" — только при явном указании для морской/ж/д контейнерной перевозки", "container_type": "количество и типоразмер ISO-контейнеров (как в критерии «Количество и типоразмер контейнеров»), если указано", "vehicle_type": "тип автотранспорта для автодороги, если указано", "temperature_range": "температурный режим, если указан", "customs_clearance_required": true, "customs_clearance_place_export_rf": "место оформления", "fumigation_on_wooden_packaging": true, "requested_shipping_type_names": ["ТОЧНЫЕ name из списка типов: все запрошенные/релевантные для этой партии; один элемент если способ один; все типы из списка если клиент просит любой/все варианты"], "shipping_type": "одно имя из списка если requested_shipping_type_names длины 1, иначе \"\"", "shipping_options": [ {{ "mode": "air|sea|road|rail", "cost": "3500 USD", "transit_time": "3-5 дней", "details": "прямой рейс, с таможенным оформлением" }} ] }} ] }} ПРАВИЛА ЗАПОЛНЕНИЯ: Если информация отсутствует — используй null для чисел и для булевых полей (не ставь false, если факт неизвестен), пустую строку "" для текста, пустой массив [] для списков. Значение false для булевых — только если в письме явно указано отрицание. document_replacement_needed — только по явным фразам про замену документов; иначе null. Обязательно заполни requested_shipping_type_names по правилам блока «ВЫБОР ТИПОВ ПЕРЕВОЗКИ»; shipping_type — дублирование при одном типе, иначе "". В ID_emails укажи список ID писем, из которых взята информация для этой перевозки cargo_ready_date — дата готовности груза (ready date, ETD от производителя, «готов к отгрузке с …»): строка или массив строк; не выдумывай. Несколько дат для разных складов/партий — все в массиве или через запятую в одной строке. dimensions — массив объектов, по одному на каждое грузовое место; поля length_cm, width_cm, height_cm — в сантиметрах. Если в письме явно указаны мм (или числа вида 1200×800×600 без «см»), переведи в см или укажи в объекте dimension_unit: \"cm\" | \"mm\" | \"m\" (мм и м код потом приведёт к см). vehicle_dimensions — только габариты транспортного средства (авто), не груза; [] если не указаны shipment_type — "FCL"/"LCL" или "" по правилам выше (не путать с типом перевозки shipping_type) shipping_options — только варианты, явно упомянутые или описанные в письмах/вложениях (цены, сроки — только если указаны). Если в тексте нет вариантов доставки — [] (пустой массив). Не придумывай стоимость, сроки и маршруты. Контекст из писем для анализа: {context_text} Верни ТОЛЬКО валидный JSON-объект, начинающийся с {{ и заканчивающийся }}. Никакого дополнительного текста.""" try: response = self.openai_client.chat.completions.create( model="card_generation", #model="anthropic/claude-3.5-sonnet", messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": query_text} ], temperature=0, max_tokens=4000 ) answer_text = response.choices[0].message.content 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) # Детерминированно усиливаем числовые поля и габариты по тексту писем/вложений, # чтобы не брать "первое попавшееся" и чтобы размеры извлекались по всем вариантам. total_shipments = len(shipments) if isinstance(shipments, list) else 0 self._postprocess_total_shipments = total_shipments for s in shipments: self._postprocess_quantities_and_dimensions(s, sources) self._postprocess_total_shipments = None for s in shipments: self.normalize_shipment(s, shipments) self._enrich_operator_document_services(s, sources) structured_data = self._enrich_with_shipping_types(structured_data, context_text, sources) if preflight: structured_data["preflight_classification"] = preflight out = { "answer": answer_text, "structured_data": structured_data, "sources": sources, "total_emails_analyzed": len(sources) } self._save_cached_report(session_id, query_text, out) self._maybe_auto_record_learning(session_id, context_text, out) return self._normalize_api_report_payload(out) def _parse_json_response(self, answer_text: str) -> Dict: try: text = answer_text.strip() if answer_text else "" if not text: raise ValueError("Empty response from model") if "```" in text: pattern = r'```(?:json)?\s*(.*?)```' matches = re.findall(pattern, text, re.DOTALL) if matches: text = matches[0].strip() else: text = text.replace("```json", "").replace("```", "").strip() start_idx = -1 for i, char in enumerate(text): if char in '{[': start_idx = i break if start_idx == -1: raise ValueError("No JSON object or array found in response") balance = 0 in_string = False escape = False opener = text[start_idx] closer = '}' if opener == '{' else ']' end_idx = start_idx for i in range(start_idx, len(text)): char = text[i] if escape: escape = False continue if char == '\\' and in_string: escape = True continue if char == '"' and not escape: in_string = not in_string continue if in_string: continue if char == opener: balance += 1 elif char == closer: balance -= 1 if balance == 0: end_idx = i + 1 break json_str = text[start_idx:end_idx].strip() if not json_str: raise ValueError("Extracted JSON string is empty") result = json.loads(json_str) result = _normalize_dict_keys(result) if isinstance(result, list): result = {"shipments": result} return result except json.JSONDecodeError as e: logger.warning(f"JSON decode error: {e}") logger.debug(f"Failed JSON preview: {answer_text[:500] if answer_text else 'None'}...") return {"shipments": [], "parse_error": str(e), "raw_preview": answer_text[:300] if answer_text else ""} except Exception as e: logger.warning(f"Failed to parse JSON: {e}", exc_info=True) return {"shipments": [], "parse_error": str(e), "raw_preview": answer_text[:300] if answer_text else ""} def _ensure_valid_structure(self, data: Any) -> Dict: if isinstance(data, str): logger.error(f"Expected dict but got string: {data[:100]}") return {"shipments": [], "error": "Invalid response format: string instead of object"} if not isinstance(data, dict): logger.warning(f"Expected dict but got {type(data)}, creating empty structure") return {"shipments": []} if "shipments" not in data: data["shipments"] = [] sh_raw = data.get("shipments") if isinstance(sh_raw, dict): vals = list(sh_raw.values()) if vals and all(isinstance(x, dict) for x in vals): data["shipments"] = vals else: data["shipments"] = [] elif not isinstance(sh_raw, list): data["shipments"] = [] data["shipments"] = [s for s in data["shipments"] if isinstance(s, dict)] for shipment in data["shipments"]: if "ID_emails" not in shipment: shipment["ID_emails"] = [] if "shipping_options" not in shipment: shipment["shipping_options"] = [] if "dangerous_goods" not in shipment: shipment["dangerous_goods"] = {} if "dimensions" not in shipment: shipment["dimensions"] = [] if "vehicle_dimensions" not in shipment: shipment["vehicle_dimensions"] = [] if "shipping_type_candidates" not in shipment: shipment["shipping_type_candidates"] = [] if "requested_shipping_type_names" not in shipment: shipment["requested_shipping_type_names"] = [] crd = shipment.get("cargo_ready_date") if isinstance(crd, list): shipment["cargo_ready_date"] = ", ".join( str(x).strip() for x in crd if x is not None and str(x).strip() ) elif crd is not None and not isinstance(crd, str): s = str(crd).strip() shipment["cargo_ready_date"] = s if s else "" return data def _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 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 st.get("name") } out: List[str] = [] seen: set[str] = set() for x in items: if not isinstance(x, str): continue n = x.strip() if not n or n not in known: if n: logger.warning("requested_shipping_type_names: неизвестное имя %r — пропуск", n) continue if n not in seen: seen.add(n) out.append(n) return out def _build_candidates_from_type_names(self, names: List[str]) -> List[Dict]: out: List[Dict] = [] for n in names: rec = self._shipping_type_record_by_name(n) if not rec: continue cai = rec.get("criteria_ai") or rec.get("criteria") or "" out.append( { "shipping_type": rec.get("name") or n, "match_score": 1, "criteria": str(cai), } ) return out def _enrich_with_shipping_types( self, structured_data: Dict, context_text: str = "", sources: Optional[List[Dict]] = None ) -> Dict: if not self.shipping_types: logger.warning("No shipping types loaded, skipping enrichment") return structured_data shipments = structured_data.get('shipments', []) skip_keys_for_match = frozenset({ "shipping_type", "shipping_type_candidates", "requested_shipping_type_names", "criteria", "criteria_preview", "documents_found", }) for shipment in shipments: text_parts: List[str] = [] for key, value in shipment.items(): if key in skip_keys_for_match: continue if isinstance(value, str) and value: text_parts.append(value.lower()) elif isinstance(value, dict): for v in value.values(): if isinstance(v, str): text_parts.append(v.lower()) elif isinstance(value, list): for item in value: if isinstance(item, str): text_parts.append(item.lower()) if context_text: text_parts.append(context_text.lower()) combined_text = " ".join(text_parts) self._correct_multimodal_sea_rail_for_shipment(shipment, combined_text) requested_names = self._normalize_requested_shipping_type_names( shipment.get("requested_shipping_type_names") ) if requested_names: candidates = self._build_candidates_from_type_names(requested_names) if candidates: shipment["shipping_type_candidates"] = candidates shipment["requested_shipping_type_names"] = [ c["shipping_type"] for c in candidates ] shipment["shipping_type"] = ( requested_names[0] if len(requested_names) == 1 else "" ) shipment["criteria"] = ( candidates[0]["criteria"] if len(candidates) == 1 else "" ) shipment["criteria_preview"] = "" logger.info( "shipping_type_candidates из LLM (requested_shipping_type_names): %s", shipment["requested_shipping_type_names"], ) self._reconcile_shipment_load_fields(shipment, combined_text) continue if self._text_requests_all_shipping_types(combined_text): shipping_type_candidates: List[Dict] = [] for st in self.shipping_types: if not isinstance(st, dict): continue name = st.get("name") if not name: continue criteria_ai = st.get("criteria_ai") or st.get("criteria") or "" shipping_type_candidates.append({ "shipping_type": name, "match_score": 1, "criteria": str(criteria_ai), }) shipment["shipping_type_candidates"] = shipping_type_candidates shipment["shipping_type"] = "" shipment["criteria"] = "" shipment["criteria_preview"] = "" logger.info( "Shipping types: letter requests all delivery modes → %d candidates (all configured types)", len(shipping_type_candidates), ) continue # Тип перевозки задаёт LLM; здесь только подстановка criteria и согласование FCL/LCL. llm_name = (shipment.get("shipping_type") or "").strip() record = self._shipping_type_record_by_name(llm_name) if record: st_name = record.get("name") or "" criteria_ai = record.get("criteria_ai") or record.get("criteria") or "" cstr = str(criteria_ai) shipment["shipping_type"] = st_name shipment["criteria"] = cstr shipment["criteria_preview"] = "" shipment["shipping_type_candidates"] = [ {"shipping_type": st_name, "match_score": 0, "criteria": cstr} ] self._reconcile_shipment_load_fields(shipment, combined_text) continue if llm_name: shipment["criteria"] = "" shipment["criteria_preview"] = "" shipment["shipping_type_candidates"] = [ {"shipping_type": llm_name, "match_score": 0, "criteria": ""} ] self._reconcile_shipment_load_fields(shipment, combined_text) logger.warning( "shipping_type %r не найден в конфигурации типов; criteria оставлено пустым", llm_name, ) continue recipient_emails = self._recipients_for_shipment(shipment, sources or []) recipient_st = self._pick_recipient_shipping_type( recipient_emails, combined_text ) if recipient_st: criteria_ai = recipient_st.get("criteria_ai") or recipient_st.get("criteria") or "" cstr = str(criteria_ai) rname = recipient_st.get("name") or "" shipment["shipping_type_candidates"] = [ {"shipping_type": rname, "match_score": 0, "criteria": cstr} ] shipment["shipping_type"] = rname shipment["criteria"] = cstr shipment["criteria_preview"] = "" logger.info( "Shipping type по ящику получателя (LLM не указал тип): name=%s recipients=%s", rname, sorted(recipient_emails), ) else: shipment["shipping_type_candidates"] = [] shipment["shipping_type"] = "" shipment["criteria"] = "" shipment["criteria_preview"] = "" logger.info("Shipping type: LLM пусто и нет сопоставления по получателям писем") self._reconcile_shipment_load_fields(shipment, combined_text) return structured_data def _reconcile_shipment_load_fields(self, shipment: Dict, combined_text: str) -> None: """Согласует shipment_type (FCL/LCL) с выбранным shipping_type и текстом.""" if not isinstance(shipment, dict): return name = shipment.get("shipping_type") or "" implied = _shipping_type_name_implies_load_mode(name) if not implied: return t_lm = infer_container_load_mode_from_text((combined_text or "").lower()) cur_raw = shipment.get("shipment_type") cur = (cur_raw or "").strip().upper() if isinstance(cur_raw, str) else "" if cur not in ("LCL", "FCL"): shipment["shipment_type"] = implied return if cur != implied: if t_lm == cur: return if t_lm == implied: shipment["shipment_type"] = implied elif t_lm is None: shipment["shipment_type"] = implied def _save_report_to_file(self, session_id: str, data: Dict): try: reports_dir = "/opt/nek/app/reports" os.makedirs(reports_dir, exist_ok=True) timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') filename = os.path.join(reports_dir, f"{session_id}_{timestamp}.json") with open(filename, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2, default=str) logger.info(f"Report saved to {filename}") except Exception as e: logger.error(f"Failed to save report: {e}", exc_info=True) def _prepare_template_data(self, shipment: Dict, missing_fields: List[str]) -> Dict: def _first_non_empty_str(*candidates: Any, default: str = "не указан") -> str: for c in candidates: if c is None: continue s = str(c).strip() if s: return s return default _ct_src = shipment.get("container_type") _ct_raw = str(_ct_src).strip() if isinstance(_ct_src, str) and _ct_src.strip() else "" _crd = shipment.get("cargo_ready_date") if isinstance(_crd, list): _crd_str = ", ".join(str(x).strip() for x in _crd if x is not None and str(x).strip()) else: _crd_str = str(_crd).strip() if _crd is not None else "" data = { "client_name": str(shipment.get("client_name") or "Клиент").strip() or "Клиент", "cargo_ready_date": _crd_str or "не указана", "pickup_address": str(shipment.get("pickup_address") or "не указан").strip() or "не указан", "delivery_address": str(shipment.get("delivery_address") or "не указан").strip() or "не указан", "total_weight_kg": shipment.get("total_weight_kg") if shipment.get("total_weight_kg") is not None else "не указан", "package_count": shipment.get("package_count") if shipment.get("package_count") is not None else "не указан", "total_volume_cbm": shipment.get("total_volume_cbm") if shipment.get("total_volume_cbm") is not None else "не указан", "cargo_description": str(shipment.get("cargo_description") or "не указано").strip() or "не указано", "hs_code": str(shipment.get("hs_code") or "не указан").strip() or "не указан", # Три-стейт для строгой логики: неизвестно -> "Информация отсутствует" "msds_required": ( "✅ Да" if shipment.get("msds_required") is True else ("❌ Нет" if shipment.get("msds_required") is False else "Информация отсутствует") ), "missing_fields": "", "estimated_cost": "по запросу", "estimated_transit_time": "по запросу", "dimensions_str": "не указаны", "dangerous_goods_str": "Информация отсутствует", "brand_authorization_info": "Информация отсутствует", "loading_port": _first_non_empty_str( shipment.get("loading_port"), shipment.get("pickup_address") ), "discharge_port": _first_non_empty_str( shipment.get("discharge_port"), shipment.get("delivery_address") ), "shipment_type": _first_non_empty_str( shipment.get("shipment_type"), default="не указан", ), "container_type": normalize_container_type_display( _ct_raw or None, empty="не указан", ), "vehicle_type": _first_non_empty_str(shipment.get("vehicle_type"), default="не указан"), "temperature_range": _first_non_empty_str( shipment.get("temperature_range"), default="не указан" ), "vehicle_dimensions_str": "не указаны", } # Габариты dims = shipment.get("dimensions", []) if isinstance(dims, list) and dims: dim_strings = [] for d in dims: 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: dim_strings.append(f"{l}×{w}×{h} см") if dim_strings: data["dimensions_str"] = "; ".join(dim_strings) 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] 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) sections.append( f"Перевозка ({i}) — {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 = """ Составь полный структурированный отчёт о всех грузоперевозках из предоставленных писем. Для каждой перевозки укажи все доступные детали: маршрут, характеристики груза, особые условия, требуемые документы и предложи варианты доставки. """ 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) if session_id: self._save_report_to_file(session_id, result) return result