From f554962d508db71c410d512b25c528899aa81cc4 Mon Sep 17 00:00:00 2001 From: "m.milnikov" Date: Sat, 11 Apr 2026 09:47:09 +0000 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B2=20=C2=AB?= =?UTF-8?q?/=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main(1).py | 502 ++++ proxy.py | 94 + rag_engine_gemini(1).py | 4637 +++++++++++++++++++++++++++++++++++++ shipping_calculator(1).py | 119 + shipping_types(1).json | 101 + 5 files changed, 5453 insertions(+) create mode 100644 main(1).py create mode 100644 proxy.py create mode 100644 rag_engine_gemini(1).py create mode 100644 shipping_calculator(1).py create mode 100644 shipping_types(1).json diff --git a/main(1).py b/main(1).py new file mode 100644 index 0000000..9a0bc01 --- /dev/null +++ b/main(1).py @@ -0,0 +1,502 @@ +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, Response +from typing import List, Optional, Dict +from fastapi import FastAPI, UploadFile, File, HTTPException, Form, Body +from fastapi.responses import JSONResponse +from pydantic import BaseModel +import uvicorn +from rag_engine_gemini import RAGEngineGemini +import logging +from prometheus_fastapi_instrumentator import Instrumentator +import asyncio +import hashlib +from datetime import datetime +from fastapi.middleware.cors import CORSMiddleware +import os +import json +import base64 +from urllib.parse import quote + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +os.makedirs("/opt/rag-gemini/app/reports", exist_ok=True) +SHIPPING_TYPES_FILE = os.path.join(os.path.dirname(__file__), "shipping_types.json") +PROCESSED_SHIPPING_TYPES_FILE = os.path.join( + os.path.dirname(__file__), "shipping_types_processed.json" +) + + +class ShippingTypeBase(BaseModel): + name: str + criteria: str = "" + keywords: List[str] = [] + employee_email: str = "" + confirmation_template: str = "" + info_request_template: str = "" + + +class ShippingTypeCreate(ShippingTypeBase): + pass + + +class ShippingTypeUpdate(ShippingTypeBase): + pass + + +class ShippingType(ShippingTypeBase): + id: int + + +def load_shipping_types(): + """Загружает типы перевозок с нормализацией ключей""" + if not os.path.exists(SHIPPING_TYPES_FILE): + return [] + with open(SHIPPING_TYPES_FILE, "r", encoding="utf-8") as f: + types = json.load(f) + + # Нормализуем ключи (убираем пробелы) и keywords + normalized_types = [] + for t in types: + normalized = {} + for key, value in t.items(): + clean_key = key.strip() + if clean_key == "keywords" and isinstance(value, list): + # Нормализуем keywords - каждый элемент отдельно, убираем пробелы и кавычки + normalized[clean_key] = [kw.strip().strip('"').strip("'") for kw in value if kw.strip()] + elif isinstance(value, str): + normalized[clean_key] = value.strip() + else: + normalized[clean_key] = value + normalized_types.append(normalized) + + return normalized_types + + +def save_shipping_types(types): + """Сохраняет типы перевозок в файл""" + with open(SHIPPING_TYPES_FILE, "w", encoding="utf-8") as f: + json.dump(types, f, ensure_ascii=False, indent=2) + + +def load_processed_shipping_criteria() -> Dict[str, str]: + """Loads AI-processed criteria per shipping type name.""" + if not os.path.exists(PROCESSED_SHIPPING_TYPES_FILE): + return {} + try: + with open(PROCESSED_SHIPPING_TYPES_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + return {str(k): str(v) for k, v in data.items() if v is not None} + return {} + except Exception as e: + logger.warning(f"Failed to load processed shipping criteria: {e}") + return {} + + +def save_processed_shipping_criteria(mapping: Dict[str, str]) -> None: + """Saves AI-processed criteria per shipping type name.""" + with open(PROCESSED_SHIPPING_TYPES_FILE, "w", encoding="utf-8") as f: + json.dump(mapping, f, ensure_ascii=False, indent=2) + + +def upsert_processed_shipping_criteria(type_name: str, processed_criteria: str) -> None: + if not isinstance(type_name, str) or not type_name.strip(): + return + processed_criteria = processed_criteria if isinstance(processed_criteria, str) else "" + mapping = load_processed_shipping_criteria() + mapping[type_name] = processed_criteria + save_processed_shipping_criteria(mapping) + + +app = FastAPI(title="SEPTEM Cargo RAG System") +app.mount("/addin", StaticFiles(directory=os.path.join(os.getcwd(), "addin"), html=True), name="addin") + +OLD_HOST = "nec.septem.pro" +NEW_HOST = "nec.clients.septem.pro" + + +@app.middleware("http") +async def redirect_legacy_host(request, call_next): + host = request.headers.get("host", "").split(":")[0].lower() + if host == OLD_HOST: + target_url = f"https://{NEW_HOST}{request.url.path}" + if request.url.query: + target_url = f"{target_url}?{request.url.query}" + return JSONResponse( + status_code=308, + content={"detail": "Permanent Redirect"}, + headers={"Location": target_url}, + ) + return await call_next(request) + + +@app.get("/addin/taskpane.html") +async def get_taskpane(): + if os.path.exists("addin/taskpane.html"): + return FileResponse("addin/taskpane.html") + elif os.path.exists("frontend/taskpane.html"): + return FileResponse("frontend/taskpane.html") + else: + return {"error": "taskpane.html not found"} + + +@app.get("/addin/commands.html") +async def get_commands(): + if os.path.exists("addin/commands.html"): + return FileResponse("addin/commands.html") + elif os.path.exists("frontend/commands.html"): + return FileResponse("frontend/commands.html") + else: + return {"error": "commands.html not found"} + + +rag = RAGEngineGemini() +Instrumentator().instrument(app).expose(app) + +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "https://nec.clients.septem.pro", + "https://localhost:3000", + "http://localhost:8501" + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["Content-Disposition", "Content-Length"] +) + + +class EmailAttachment(BaseModel): + filename: str + size: int + content: Optional[str] = None + + +class OutlookEmail(BaseModel): + id: str + subject: str + sender: str + senderName: Optional[str] = None + body: str + body_html: Optional[str] = None + receivedTime: Optional[str] = None + to: Optional[str] = None + cc: Optional[str] = None + attachments: List[EmailAttachment] = [] + +class OutlookEmailsRequest(BaseModel): + emails: List[OutlookEmail] + session_id: Optional[str] = None + + +class CargoQueryRequest(BaseModel): + query: str + session_id: Optional[str] = None + top_k: int = 10 + + +class CargoQueryResponse(BaseModel): + answer: str + structured_data: dict + sources: list + total_emails_analyzed: int + + +class AnalyzeCargoRequest(BaseModel): + email_ids: List[str] + + +class CargoLearningRequest(BaseModel): + """Сохранение примера для обучения: переписка + структурированный ответ (как в отчёте).""" + structured_data: dict + session_id: Optional[str] = None + context_preview: Optional[str] = None + notes: Optional[str] = None + + +@app.post("/record-cargo-learning") +async def record_cargo_learning(req: CargoLearningRequest): + """ + Записывает пару «контекст писем → JSON отчёта» в локальное хранилище few-shot. + Если передан session_id, текст писем подставляется из текущей сессии сервера. + """ + try: + ok = rag.record_cargo_learning( + structured_data=req.structured_data, + session_id=req.session_id, + context_preview=req.context_preview, + notes=req.notes, + ) + return {"status": "ok" if ok else "skipped", "stored": ok} + except Exception as e: + logger.error(f"record-cargo-learning error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/process-outlook-emails") +async def process_outlook_emails(request: OutlookEmailsRequest): + try: + session_id = await rag.process_outlook_emails( + [email.model_dump() for email in request.emails], + session_id=request.session_id + ) + return { + "status": "success", + "session_id": session_id, + "emails_processed": len(request.emails) + } + except Exception as e: + logger.error(f"Process Outlook emails error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/query-cargo", response_model=CargoQueryResponse) +async def query_cargo(request: CargoQueryRequest): + try: + result = await rag.query_cargo_info( + request.query, + request.session_id, + request.top_k + ) + return result + except Exception as e: + logger.error(f"Cargo query error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/generate-cargo-report") +async def generate_cargo_report(session_id: str = Body(..., embed=True)): + try: + result = await rag.generate_cargo_report(session_id) + return result + except Exception as e: + logger.error(f"Generate report error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/email-sessions/{session_id}") +async def get_session_info(session_id: str): + return { + "session_id": session_id, + "created_at": datetime.now().isoformat(), + "emails_count": 10 + } + + +@app.post("/upload", include_in_schema=False) +async def upload_document(file: UploadFile = File(...)): + try: + content = await file.read() + doc_id = hashlib.md5(file.filename.encode()).hexdigest() + asyncio.create_task(rag.process_document(content, file.filename)) + return {"status": "processing", "document_id": doc_id} + except Exception as e: + logger.error(f"Upload error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/health") +async def health(): + return {"status": "healthy"} + + +# ============================================================================= +# 🔥 ENDPOINT ДЛЯ ПРОСМОТРА ВЛОЖЕНИЙ В БРАУЗЕРЕ (ИСПРАВЛЕННЫЙ) +# ============================================================================= +@app.get("/attachments/{session_id}/{email_index}/{attachment_index}") +async def get_attachment(session_id: str, email_index: int, attachment_index: int): + """ + Возвращает оригинальный файл вложения для просмотра в браузере. + Поддерживает PDF, DOCX, XLSX, изображения и другие форматы. + Корректно обрабатывает русские имена файлов (RFC 5987). + """ + att = rag.get_attachment(session_id, email_index, attachment_index) + if att is None: + raise HTTPException(status_code=404, detail="Attachment not found") + + filename = att.get("filename", "attachment") + content_base64 = att.get("content_base64") + + # 🔥 Функция для безопасного формирования заголовка Content-Disposition + def make_content_disposition(filename: str, disposition: str = "inline") -> str: + """ + Формирует Content-Disposition с поддержкой UTF-8 имён файлов (RFC 5987). + Совместимо со старыми и новыми браузерами. + """ + # ASCII-версия для старых браузеров (fallback) + ascii_filename = filename.encode('ascii', 'ignore').decode('ascii') or 'attachment' + + # UTF-8 версия с URL-кодированием для современных браузеров (RFC 5987) + utf8_filename = quote(filename, safe='') + + return f'{disposition}; filename="{ascii_filename}"; filename*=UTF-8\'\'{utf8_filename}' + + # Если нет оригинального содержимого, возвращаем извлечённый текст + if not content_base64: + text = att.get("text", "") + return Response( + content=text, + media_type="text/plain; charset=utf-8", + headers={ + "Content-Disposition": make_content_disposition(filename + ".txt"), + "Content-Length": str(len(text.encode('utf-8'))), + "Access-Control-Expose-Headers": "Content-Disposition, Content-Length" + } + ) + + # 🔥 Декодируем base64 и определяем MIME тип по расширению + try: + file_content = base64.b64decode(content_base64) + + # Определяем MIME тип по расширению файла + ext = filename.lower().split('.')[-1] if '.' in filename else '' + mime_types = { + 'pdf': 'application/pdf', + 'doc': 'application/msword', + 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls': 'application/vnd.ms-excel', + 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'txt': 'text/plain', + 'csv': 'text/csv', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'bmp': 'image/bmp', + 'zip': 'application/zip', + 'rar': 'application/vnd.rar', + 'ppt': 'application/vnd.ms-powerpoint', + 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'rtf': 'application/rtf', + 'xml': 'application/xml', + 'json': 'application/json', + } + + media_type = mime_types.get(ext, 'application/octet-stream') + + # 🔥 Возвращаем файл с правильным заголовком Content-Disposition + return Response( + content=file_content, + media_type=media_type, + headers={ + "Content-Disposition": make_content_disposition(filename), + "Content-Length": str(len(file_content)), + "Access-Control-Expose-Headers": "Content-Disposition, Content-Length" + } + ) + except Exception as e: + logger.error(f"Error serving attachment: {e}") + raise HTTPException(status_code=500, detail=f"Error processing file: {str(e)}") + + +@app.get("/shipping-types", response_model=List[ShippingType]) +async def get_shipping_types(): + return load_shipping_types() + + +@app.post("/shipping-types", response_model=ShippingType) +async def create_shipping_type(item: ShippingTypeCreate): + types = load_shipping_types() + new_id = max([t["id"] for t in types], default=0) + 1 + new_item = item.model_dump() + new_item["id"] = new_id + types.append(new_item) + save_shipping_types(types) + + # AI-обработка criteria и сохранение результата + try: + type_name = new_item.get("name", "") + criteria_text = new_item.get("criteria", "") or "" + processed = rag.process_shipping_type_criteria(criteria_text) + upsert_processed_shipping_criteria(type_name, processed) + rag.reload_shipping_types() + except Exception as e: + logger.warning(f"AI criteria processing failed on create: {e}") + return new_item + + +@app.put("/shipping-types/{item_id}", response_model=ShippingType) +async def update_shipping_type(item_id: int, item: ShippingTypeUpdate): + types = load_shipping_types() + for t in types: + if t["id"] == item_id: + old_name = t.get("name", "") + t.update(item.model_dump()) + save_shipping_types(types) + + # AI-обработка criteria и сохранение результата + try: + type_name = t.get("name", "") + criteria_text = t.get("criteria", "") or "" + processed = rag.process_shipping_type_criteria(criteria_text) + + # если поменяли имя типа — чистим старый ключ + if isinstance(old_name, str) and old_name.strip() and old_name != type_name: + mapping = load_processed_shipping_criteria() + if old_name in mapping: + mapping.pop(old_name, None) + save_processed_shipping_criteria(mapping) + + upsert_processed_shipping_criteria(type_name, processed) + rag.reload_shipping_types() + except Exception as e: + logger.warning(f"AI criteria processing failed on update: {e}") + return t + raise HTTPException(status_code=404, detail="Type not found") + +@app.get("/email-attachments/{session_id}") +async def get_email_attachments(session_id: str): + + emails = rag.sessions.get(session_id, []) + + files = [] + + for email in emails: + for att in email.get("attachments", []): + + filename = att.get("filename","") + ext = filename.split(".")[-1].lower() + + # игнорируем изображения + if ext in ["png","jpg","jpeg","gif","bmp","tiff","webp"]: + continue + + if att.get("content_base64"): + files.append({ + "filename": filename, + "content_base64": att["content_base64"] + }) + + return files + +@app.delete("/shipping-types/{item_id}") +async def delete_shipping_type(item_id: int): + types = load_shipping_types() + removed = None + for t in types: + if t.get("id") == item_id: + removed = t + break + new_types = [t for t in types if t["id"] != item_id] + if len(new_types) == len(types): + raise HTTPException(status_code=404, detail="Type not found") + save_shipping_types(new_types) + + # Удаляем обработанные criteria + try: + if removed: + type_name = removed.get("name", "") + mapping = load_processed_shipping_criteria() + if type_name in mapping: + mapping.pop(type_name, None) + save_processed_shipping_criteria(mapping) + rag.reload_shipping_types() + except Exception as e: + logger.warning(f"Failed to delete processed criteria on delete: {e}") + return {"ok": True} + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/proxy.py b/proxy.py new file mode 100644 index 0000000..ae1d767 --- /dev/null +++ b/proxy.py @@ -0,0 +1,94 @@ +import logging +import os +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, Response +import httpx + +try: + from dotenv import load_dotenv + + load_dotenv() +except ImportError: + pass + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI() + +UPSTREAM_URL = os.getenv( + "LLM_UPSTREAM_URL", + "https://llm.corp.septem.pro/v1/chat/completions", +).strip() +OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "").strip() + + +def _client_timeout() -> httpx.Timeout: + total = float(os.getenv("LLM_PROXY_TIMEOUT_SECONDS", "120")) + connect = float(os.getenv("LLM_PROXY_CONNECT_TIMEOUT", "15")) + return httpx.Timeout(total, connect=connect) + + +@app.post("/v1/chat/completions") +async def proxy(req: Request): + if not OPENROUTER_KEY: + logger.error("OPENROUTER_API_KEY is not set (env or .env)") + return JSONResponse( + status_code=503, + content={ + "error": "Service unavailable", + "details": "Set OPENROUTER_API_KEY for the LLM proxy (or add it to .env).", + }, + ) + + try: + body = await req.json() + except Exception as e: + return JSONResponse( + status_code=400, + content={"error": "Invalid JSON", "details": str(e)}, + ) + + verify = os.getenv("HTTPX_VERIFY", "true").lower() not in ("0", "false", "no") + + async with httpx.AsyncClient(verify=verify) as client: + try: + r = await client.post( + UPSTREAM_URL, + headers={ + "Authorization": f"Bearer {OPENROUTER_KEY}", + "HTTP-Referer": os.getenv("LLM_HTTP_REFERER", "http://localhost"), + "X-Title": os.getenv("LLM_PROXY_X_TITLE", "rag-engine"), + }, + json=body, + timeout=_client_timeout(), + ) + except httpx.RequestError as e: + logger.error( + "LLM upstream unreachable (%s): %s", + UPSTREAM_URL, + e, + exc_info=True, + ) + return JSONResponse( + status_code=502, + content={ + "error": "Bad gateway", + "details": str(e), + "upstream": UPSTREAM_URL, + "hint": "Проверьте VPN, DNS, доступность хоста и переменную LLM_UPSTREAM_URL.", + }, + ) + + if r.status_code >= 500: + logger.warning( + "LLM upstream HTTP %s, body (prefix): %s", + r.status_code, + (r.text or "")[:800], + ) + + return Response( + content=r.content, + status_code=r.status_code, + media_type=r.headers.get("content-type", "application/json"), + ) diff --git a/rag_engine_gemini(1).py b/rag_engine_gemini(1).py new file mode 100644 index 0000000..15bf4be --- /dev/null +++ b/rag_engine_gemini(1).py @@ -0,0 +1,4637 @@ +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 diff --git a/shipping_calculator(1).py b/shipping_calculator(1).py new file mode 100644 index 0000000..8969417 --- /dev/null +++ b/shipping_calculator(1).py @@ -0,0 +1,119 @@ +import math +import re +from typing import Dict, Optional + +def calculate_shipping_cost(shipment: Dict) -> Optional[Dict]: + """ + Расчёт стоимости авиаперевозки на основе параметров груза. + Возвращает словарь с ключами: + total_usd, total_rub, chargeable_weight, actual_weight, volume_weight + или None, если не хватает данных. + """ + try: + # Проверяем наличие обязательных данных + if not all(k in shipment for k in ['total_weight_kg', 'dimensions', 'cargo_value']): + return None + + # Параметры груза + actual_weight = shipment.get('total_weight_kg', 0) + if not actual_weight or actual_weight <= 0: + return None + + # Расчёт объёмного веса + dimensions = shipment.get('dimensions', []) + if not dimensions: + return None + + total_volume_weight = 0 + for dim in dimensions: + if dim.get('length_cm') and dim.get('width_cm') and dim.get('height_cm'): + # Объёмный вес = (Д×Ш×В в метрах) × 167 + length_m = dim['length_cm'] / 100 + width_m = dim['width_cm'] / 100 + height_m = dim['height_cm'] / 100 + volume = length_m * width_m * height_m + volume_weight = volume * 167 + total_volume_weight += volume_weight + + # Платный вес (оплачивается по большему) + chargeable_weight = max(actual_weight, total_volume_weight) + chargeable_weight = math.ceil(chargeable_weight) # округляем вверх + + # Базовый тариф в зависимости от веса (USD/кг) + if chargeable_weight < 45: + base_rate = 6.5 + elif chargeable_weight < 100: + base_rate = 5.5 + elif chargeable_weight < 300: + base_rate = 4.5 + elif chargeable_weight < 500: + base_rate = 3.8 + elif chargeable_weight < 1000: + base_rate = 3.2 + else: + base_rate = 2.8 + + # Топливный сбор и сбор за безопасность + fuel_surcharge_rate = 0.9 # USD/кг + min_fuel_surcharge = 90 # USD минимум + + # Терминальные сборы + terminal_export_rate = 0.22 # USD/кг (~20 руб/кг) + terminal_export_fixed = 25 # USD фиксированных сборов (~2300 руб) + terminal_import_rate = 0.35 # EUR/кг ≈ 0.38 USD/кг по курсу 1.1 + + # Таможенное оформление + customs_export = 170 # USD (~15500 руб) + customs_import = 220 # EUR ≈ 242 USD + + # Надбавки за особые условия + special_rate_multiplier = 1.0 + + # Терморежим (+15…+25 °C) + if shipment.get('special_transport_requirements') and 'термо' in str(shipment.get('special_transport_requirements', '')).lower(): + special_rate_multiplier *= 1.3 # +30% + + # Страхование + cargo_value_usd = 0 + cargo_value_str = shipment.get('cargo_value', '0') + # Извлекаем числовое значение из строки (например, "18 500 EUR" -> 18500) + value_match = re.search(r'[\d\s]+', cargo_value_str.replace(' ', '')) + if value_match: + cargo_value_usd = float(value_match.group().replace(' ', '')) + # Если указано в EUR, конвертируем + if 'EUR' in cargo_value_str or '€' in cargo_value_str: + cargo_value_usd *= 1.1 # приблизительный курс EUR/USD + + insurance_cost = cargo_value_usd * 0.01 # 1% от стоимости груза + + # Расчёт компонентов + base_freight = chargeable_weight * base_rate * special_rate_multiplier + fuel_surcharge = max(chargeable_weight * fuel_surcharge_rate, min_fuel_surcharge) + terminal_export = chargeable_weight * terminal_export_rate + terminal_export_fixed + terminal_import = chargeable_weight * 0.38 # конвертация EUR->USD + customs_total = customs_export + customs_import + + # Дополнительные услуги + additional_services = shipment.get('additional_services', []) + additional_cost = 0 + if isinstance(additional_services, list): + if 'уведомление о прибытии' in str(additional_services).lower(): + additional_cost += 25 # USD + + # Итог + total_usd = (base_freight + fuel_surcharge + terminal_export + + terminal_import + customs_total + additional_cost + insurance_cost) + + # Конвертация в рубли для удобства (курс USD/RUB ~90) + total_rub = total_usd * 90 + + return { + "total_usd": round(total_usd, 2), + "total_rub": round(total_rub, 2), + "chargeable_weight": chargeable_weight, + "actual_weight": actual_weight, + "volume_weight": round(total_volume_weight, 2) + } + except Exception as e: + # Логирование ошибки, если нужно + return None diff --git a/shipping_types(1).json b/shipping_types(1).json new file mode 100644 index 0000000..db2ad1e --- /dev/null +++ b/shipping_types(1).json @@ -0,0 +1,101 @@ +[ + { + "id": 1, + "name": "Автомобильная перевозка (LTL)", + "employee_email": "denis.fomin@timnet.ch, Yana.Schkabrova@timnet.ch", + "extra_required_fields": ["hs_code", "customs_clearance_place_export_rf"], + "mandatory_counterparty_criteria": "По макету интерфейса обязательно отражать в письме контрагенту и запрашивать у клиента при отсутствии: название клиента; Incoterms; характер/наименование груза и код ТН ВЭД; страна, город, адрес забора; страна, город, точный адрес доставки; место таможенного оформления в РФ; количество грузовых мест и вес; габариты грузовых мест (Д×Ш×В).", + "criteria": "1\tНазвание клиента\n2\tУсловия поставки Incoterms\n3\tДата готовности груза\n4\tСтрана, город, адрес забора\n5\tСтоимость груза\n6\tКоличество грузовых мест и вес\n7\tГабариты каждого грузового места в см или мм (длина × ширина × высота)\n8\tХарактер груза или наименование груза, код ТН ВЭД\n9\tСодержатся ли в грузе батарейки, газы под давлением, жидкости\n10\tМожно ли штабелировать груз с другими отправками\n11\tЕсли груз нельзя штабелировать с другими грузами — можно ли штабелировать грузовые места между собой\n12\tЕсли груз — химия, литиевые батареи, жидкости, аэрозоли, газы или порошки — предоставить MSDS\n13\tЕсли есть батарейки — в составе груза или отдельно упакованы\n14\tЕсли груз из п.13 отгружается из материкового Китая по ж/д — дополнительно DGM\n15\tНазвание бренда\n16\tЕсли бренд зарегистрирован в таможенной системе Китая — есть ли у отправителя авторизационное письмо/разрешение на вывоз бренда\n17\tДополнительные сервисы (фитоконтроль и т.п.)\n18\tТребуется ли замена документов\n19\tСтрана, город, точный адрес доставки\n20\tМесто таможенного оформления в РФ", + "keywords": ["ltl", "сборный трак", "догруз", "группаж авто", "less than truckload", "неполная фура", "авто ltl", "мелкий груз авто", "road ltl", "partial truckload"], + "confirmation_template": "Dear Partner!\n\nThank you for contacting SEPTEM. We are pleased to confirm the possibility of road freight (LTL) for your cargo under the following terms:\n\n- Route: (Pickup address) -> (Delivery address)\n- Cargo weight: (Cargo weight) kg\n- Number of packages: (Number of packages)\n- Volume: (Volume) m3\n- Dimensions: (Dimensions)\n- Cargo description: (Cargo description)\n- HS code: (HS code)\n- Dangerous properties: (Dangerous properties)\n- MSDS required: (MSDS required)\n- Freight cost: (Estimated cost)\n- Estimated transit time: (Transit time)\n\nTo confirm the order and issue the invoice, please reply to this email or contact your manager.\n\nBest regards,\nSEPTEM Cargo Team", + "info_request_template": "Уважаемый (Клиент)!\n\nБлагодарим вас за обращение в компанию SEPTEM. Для расчёта автомобильной перевозки (LTL) нам необходима следующая информация:\n\n(Необходимая информация)\n\nПожалуйста, дополните данные по вашему грузу. Вы можете ответить на это письмо или загрузить недостающие документы через личный кабинет.\n\nС уважением,\nКоманда SEPTEM Cargo" + }, + { + "id": 2, + "name": "Автомобильная перевозка (FTL)", + "employee_email": "denis.fomin@timnet.ch, Yana.Schkabrova@timnet.ch", + "extra_required_fields": ["hs_code", "vehicle_type", "customs_clearance_place_export_rf"], + "mandatory_counterparty_criteria": "Обязательно в письме и при запросе клиенту: клиент; Incoterms; характер/наименование груза и ТН ВЭД; адрес забора; адрес доставки; место таможенного оформления в РФ; количество и типоразмер машин; вес груза в машине.", + "criteria": "1\tНазвание клиента\n2\tУсловия поставки Incoterms\n3\tДата готовности груза\n4\tСтрана, город, адрес забора\n5\tСтоимость груза\n6\tКоличество и типоразмер машин\n7\tВес груза в машине\n8\tЕсли нет данных о количестве машин — габариты каждого грузового места (Д×Ш×В в см или мм) и вес каждого места\n9\tХарактер груза или наименование груза, код ТН ВЭД\n10\tСодержатся ли в грузе батарейки, газы под давлением, жидкости\n11\tХимия, литиевые батареи, жидкости, аэрозоли, газы или порошки — MSDS\n12\tБатарейки — в составе груза или отдельно упакованы\n13\tОтгрузка из материкового Китая по ж/д — DGM\n14\tНазвание бренда\n15\tЕсли бренд зарегистрирован в таможенной системе Китая — есть ли у отправителя авторизационное письмо/разрешение на вывоз бренда\n16\tДополнительные сервисы (фитоконтроль и т.п.)\n17\tТребуется ли замена документов\n18\tСтрана, город, точный адрес доставки\n19\tМесто таможенного оформления в РФ", + "keywords": ["ftl", "полная фура", "целая машина", "full truckload", "отдельный автомобиль", "авто ftl", "тент фура целиком", "road ftl", "exclusive truck"], + "confirmation_template": "Dear Partner!\n\nThank you for contacting SEPTEM. We are pleased to confirm the possibility of road freight (FTL) for your cargo under the following terms:\n\n- Route: (Pickup address) -> (Delivery address)\n- Vehicle type: (Vehicle type)\n- Cargo weight: (Cargo weight) kg\n- Number of packages: (Number of packages)\n- Volume: (Volume) m3\n- Dimensions: (Dimensions)\n- Cargo description: (Cargo description)\n- HS code: (HS code)\n- Dangerous properties: (Dangerous properties)\n- MSDS required: (MSDS required)\n- Freight cost: (Estimated cost)\n- Transit time: (Transit time)\n\nBest regards,\nSEPTEM Cargo Team", + "info_request_template": "Уважаемый (Клиент)!\n\nБлагодарим за обращение в SEPTEM. Для расчёта автомобильной перевозки (FTL) нам необходима следующая информация:\n\n(Необходимая информация)\n\nПожалуйста, дополните данные по грузу.\n\nС уважением,\nКоманда SEPTEM Cargo" + }, + { + "id": 3, + "name": "Морская перевозка (LCL)", + "employee_email": "marina.eremina@timnet.ch, ekaterina.kochenkova@timnet.ch, Yana.Schkabrova@timnet.ch", + "extra_required_fields": ["hs_code", "stackable_with_others"], + "mandatory_counterparty_criteria": "Обязательно: клиент; Incoterms; характер груза и ТН ВЭД; адрес забора; адрес доставки; количество мест и вес; габариты Д×Ш×В; возможность штабелирования с другими отправками.", + "criteria": "1\tНазвание клиента\n2\tУсловия поставки Incoterms\n3\tДата готовности груза\n4\tСтрана, город, адрес забора\n5\tСтоимость груза\n6\tКоличество грузовых мест и вес\n7\tГабариты каждого грузового места (Д×Ш×В в см или мм)\n8\tХарактер груза или наименование, код ТН ВЭД\n9\tБатарейки, газы, газы под давлением, жидкости в грузе\n10\tМожно ли штабелировать груз с другими отправками\n11\tЕсли нельзя с другими — можно ли штабелировать места между собой\n12\tХимия, литиевые батареи, жидкости, аэрозоли, газы, порошки — MSDS\n13\tБатарейки — в составе или отдельно\n14\tОтгрузка из материкового Китая по ж/д — Technical Description of Goods in Railway Transport\n15\tНазвание бренда\n16\tЕсли бренд зарегистрирован в таможенной системе Китая — есть ли у отправителя авторизационное письмо/разрешение на вывоз бренда\n17\tДополнительные сервисы (фитоконтроль и т.п.)\n18\tСтрана, город, точный адрес доставки\n\nПрименимо к морской сборной перевозке (LCL): порт погрузки/выгрузки и морской этап явно указаны в переписке; без доминирования ж/д как основного этапа.", + "keywords": ["море lcl", "морская сборная", "морской lcl", "sea lcl", "сборный морской", "консолидация море", "groupage sea", "lcl порт", "морской группаж", "неполный контейнер море"], + "confirmation_template": "Dear Partner!\n\nThank you for contacting SEPTEM. We confirm the possibility of sea freight (LCL) under the following terms:\n\n- Route: (Port of loading) -> (Port of discharge)\n- Shipment type: LCL\n- Cargo weight: (Cargo weight) kg\n- Packages: (Number of packages)\n- Volume: (Volume) m3\n- Dimensions: (Dimensions)\n- Cargo: (Cargo description)\n- HS code: (HS code)\n- Dangerous properties: (Dangerous properties)\n- MSDS: (MSDS required)\n- Freight: (Estimated cost)\n- Transit: (Transit time)\n\nBest regards,\nSEPTEM Cargo Team", + "info_request_template": "Уважаемый (Клиент)!\n\nДля расчёта морской сборной перевозки (LCL) нам необходима следующая информация:\n\n(Необходимая информация)\n\nПожалуйста, дополните данные.\n\nС уважением,\nКоманда SEPTEM Cargo" + }, + { + "id": 4, + "name": "Железнодорожная перевозка (LCL)", + "employee_email": "marina.eremina@timnet.ch, ekaterina.kochenkova@timnet.ch, Yana.Schkabrova@timnet.ch", + "extra_required_fields": ["hs_code", "stackable_with_others"], + "mandatory_counterparty_criteria": "Обязательно: клиент; Incoterms; характер груза и ТН ВЭД; адрес забора; адрес доставки; количество мест и вес; габариты; штабелирование с другими отправками.", + "criteria": "1\tНазвание клиента\n2\tУсловия поставки Incoterms\n3\tДата готовности груза\n4\tСтрана, город, адрес забора\n5\tСтоимость груза\n6\tКоличество грузовых мест и вес\n7\tГабариты каждого грузового места (Д×Ш×В в см или мм)\n8\tХарактер груза или наименование, код ТН ВЭД\n9\tБатарейки, газы, газы под давлением, жидкости в грузе\n10\tМожно ли штабелировать груз с другими отправками\n11\tЕсли нельзя с другими — можно ли штабелировать места между собой\n12\tХимия, литиевые батареи, жидкости, аэрозоли, газы, порошки — MSDS\n13\tБатарейки — в составе или отдельно\n14\tОтгрузка из материкового Китая по ж/д — Technical Description of Goods in Railway Transport\n15\tНазвание бренда\n16\tЕсли бренд зарегистрирован в таможенной системе Китая — есть ли у отправителя авторизационное письмо/разрешение на вывоз бренда\n17\tДополнительные сервисы (фитоконтроль и т.п.)\n18\tСтрана, город, точный адрес доставки\n\nПрименимо к ж/д сборной перевозке (LCL): станции/терминалы ж/д, контейнер сборный по ж/д; морской этап в запросе не основной.", + "keywords": ["жд lcl", "ж/д lcl", "rail lcl", "сборный контейнер жд", "сборная жд", "lcl поезд", "группаж жд", "железная дорога сборный"], + "confirmation_template": "Dear Partner!\n\nThank you for contacting SEPTEM. We confirm rail freight (LCL) under the following terms:\n\n- Route: (Pickup address) -> (Delivery address)\n- Shipment type: LCL\n- Weight: (Cargo weight) kg\n- Packages: (Number of packages)\n- Volume: (Volume) m3\n- Dimensions: (Dimensions)\n- Cargo: (Cargo description)\n- HS: (HS code)\n- MSDS: (MSDS required)\n- Cost: (Estimated cost)\n- Transit: (Transit time)\n\nBest regards,\nSEPTEM Cargo Team", + "info_request_template": "Уважаемый (Клиент)!\n\nДля расчёта железнодорожной сборной перевозки (LCL) нам необходима следующая информация:\n\n(Необходимая информация)\n\nС уважением,\nКоманда SEPTEM Cargo" + }, + { + "id": 5, + "name": "Мультимодальная перевозка море + ж/д (LCL)", + "employee_email": "marina.eremina@timnet.ch, ekaterina.kochenkova@timnet.ch, Yana.Schkabrova@timnet.ch", + "extra_required_fields": ["hs_code", "stackable_with_others"], + "mandatory_counterparty_criteria": "Обязательно: клиент; Incoterms; характер груза и ТН ВЭД; адрес забора; адрес доставки; количество мест и вес; габариты; штабелирование с другими отправками.", + "criteria": "1\tНазвание клиента\n2\tУсловия поставки Incoterms\n3\tДата готовности груза\n4\tСтрана, город, адрес забора\n5\tСтоимость груза\n6\tКоличество грузовых мест и вес\n7\tГабариты каждого грузового места (Д×Ш×В в см или мм)\n8\tХарактер груза или наименование, код ТН ВЭД\n9\tБатарейки, газы, газы под давлением, жидкости в грузе\n10\tМожно ли штабелировать груз с другими отправками\n11\tЕсли нельзя с другими — можно ли штабелировать места между собой\n12\tХимия, литиевые батареи, жидкости, аэрозоли, газы, порошки — MSDS\n13\tБатарейки — в составе или отдельно\n14\tОтгрузка из материкового Китая по ж/д — Technical Description of Goods in Railway Transport\n15\tНазвание бренда\n16\tЕсли бренд зарегистрирован в таможенной системе Китая — есть ли у отправителя авторизационное письмо/разрешение на вывоз бренда\n17\tДополнительные сервисы (фитоконтроль и т.п.)\n18\tСтрана, город, точный адрес доставки\n\nКритерии как для сборного груза при цепочке море и ж/д (интермодаль): в переписке явно присутствуют и морской, и железнодорожный этапы.", + "keywords": ["море жд", "море+жд", "море и жд", "sea rail", "интермодальн", "multimodal lcl", "морской и жд этап", "порт и станция сборный"], + "confirmation_template": "Dear Partner!\n\nThank you for contacting SEPTEM. We confirm multimodal sea+rail (LCL) under the following terms:\n\n- Route: (Pickup address) -> (Delivery address)\n- Modes: sea + rail (LCL)\n- Weight: (Cargo weight) kg\n- Packages: (Number of packages)\n- Volume: (Volume) m3\n- Dimensions: (Dimensions)\n- Cargo: (Cargo description)\n- HS: (HS code)\n- MSDS: (MSDS required)\n- Cost: (Estimated cost)\n- Transit: (Transit time)\n\nBest regards,\nSEPTEM Cargo Team", + "info_request_template": "Уважаемый (Клиент)!\n\nДля расчёта мультимодальной перевозки (море + ж/д, LCL) нам необходима следующая информация:\n\n(Необходимая информация)\n\nС уважением,\nКоманда SEPTEM Cargo" + }, + { + "id": 6, + "name": "Морская перевозка (FCL)", + "employee_email": "marina.eremina@timnet.ch, ekaterina.kochenkova@timnet.ch, Yana.Schkabrova@timnet.ch", + "extra_required_fields": ["hs_code", "container_type"], + "mandatory_counterparty_criteria": "Обязательно: клиент; Incoterms; характер груза и ТН ВЭД; адрес забора; адрес доставки; количество и типоразмер контейнеров; вес груза в контейнере.", + "criteria": "1\tНазвание клиента\n2\tУсловия поставки Incoterms\n3\tДата готовности груза\n4\tСтрана, город, адрес забора\n5\tСтоимость груза\n6\tКоличество и типоразмер контейнеров\n7\tВес груза в контейнере\n8\tЕсли нет данных о контейнерах — габариты каждого грузового места (Д×Ш×В) и вес мест\n9\tХарактер груза или наименование, код ТН ВЭД\n10\tБатарейки, газы, жидкости\n11\tMSDS при необходимости\n12\tБатарейки в составе или отдельно\n13\tИз материкового Китая по ж/д — Technical Description of Goods in Railway Transport\n14\tНазвание бренда\n15\tЕсли бренд зарегистрирован в таможенной системе Китая — есть ли у отправителя авторизационное письмо/разрешение на вывоз бренда\n16\tДополнительные сервисы (крепление в контейнере, фитосанитарный контроль и т.п.)\n17\tТребуется ли замена документов\n18\tСтрана, город, точный адрес доставки\n19\tМесто таможенного оформления при экспорте из РФ\n20\tФумигация деревянной тары при экспорте из РФ\n21\tПроверка габаритов грузовых мест на проходимость в дверной проём контейнеров 20DV, 40DV, 40HC\n\nМорской FCL: ISO-контейнеры под морской фрахт и погрузку на судно.", + "keywords": ["морской fcl", "море fcl", "sea fcl", "полный контейнер море", "морской контейнер", "fcl порт", "цельный контейнер море", "ocean fcl"], + "confirmation_template": "Dear Partner!\n\nThank you for contacting SEPTEM. We confirm sea freight (FCL):\n\n- Port: (Port of loading) -> (Port of discharge)\n- Container: (Container type)\n- Weight: (Cargo weight) kg\n- Cargo: (Cargo description)\n- HS: (HS code)\n- MSDS/IMDG: (MSDS required)\n- Cost: (Estimated cost)\n- Transit: (Transit time)\n\nBest regards,\nSEPTEM Cargo Team", + "info_request_template": "Уважаемый (Клиент)!\n\nДля расчёта морской перевозки (FCL) нам необходима следующая информация:\n\n(Необходимая информация)\n\nС уважением,\nКоманда SEPTEM Cargo" + }, + { + "id": 7, + "name": "Железнодорожная перевозка (FCL)", + "employee_email": "marina.eremina@timnet.ch, ekaterina.kochenkova@timnet.ch, Yana.Schkabrova@timnet.ch", + "extra_required_fields": ["hs_code", "container_type"], + "mandatory_counterparty_criteria": "Обязательно: клиент; Incoterms; характер груза и ТН ВЭД; адрес забора; адрес доставки; количество и типоразмер контейнеров; вес груза в контейнере.", + "criteria": "1\tНазвание клиента\n2\tУсловия поставки Incoterms\n3\tДата готовности груза\n4\tСтрана, город, адрес забора\n5\tСтоимость груза\n6\tКоличество и типоразмер контейнеров\n7\tВес груза в контейнере\n8\tЕсли нет данных о контейнерах — габариты каждого грузового места (Д×Ш×В) и вес мест\n9\tХарактер груза или наименование, код ТН ВЭД\n10\tБатарейки, газы, жидкости\n11\tMSDS при необходимости\n12\tБатарейки в составе или отдельно\n13\tИз материкового Китая по ж/д — Technical Description of Goods in Railway Transport\n14\tНазвание бренда\n15\tЕсли бренд зарегистрирован в таможенной системе Китая — есть ли у отправителя авторизационное письмо/разрешение на вывоз бренда\n16\tДополнительные сервисы (крепление в контейнере, фитосанитарный контроль и т.п.)\n17\tТребуется ли замена документов\n18\tСтрана, город, точный адрес доставки\n19\tМесто таможенного оформления при экспорте из РФ\n20\tФумигация деревянной тары при экспорте из РФ\n21\tПроверка габаритов грузовых мест на проходимость в дверной проём контейнеров 20DV, 40DV, 40HC\n\nЖ/д FCL: ISO 20'/40' на платформах/вагонных комплектах (1520 мм); без обязательного морского этапа.", + "keywords": ["жд fcl", "ж/д fcl", "rail fcl", "контейнер по жд целый", "полный контейнер жд", "fcl поезд", "вагон контейнер"], + "confirmation_template": "Dear Partner!\n\nThank you for contacting SEPTEM. We confirm rail freight (FCL):\n\n- Route: (Pickup address) -> (Delivery address)\n- Container: (Container type)\n- Weight: (Cargo weight) kg\n- Cargo: (Cargo description)\n- HS: (HS code)\n- MSDS: (MSDS required)\n- Cost: (Estimated cost)\n- Transit: (Transit time)\n\nBest regards,\nSEPTEM Cargo Team", + "info_request_template": "Уважаемый (Клиент)!\n\nДля расчёта железнодорожной перевозки (FCL) нам необходима следующая информация:\n\n(Необходимая информация)\n\nС уважением,\nКоманда SEPTEM Cargo" + }, + { + "id": 8, + "name": "Мультимодальная перевозка море + ж/д (FCL)", + "employee_email": "marina.eremina@timnet.ch, ekaterina.kochenkova@timnet.ch, Yana.Schkabrova@timnet.ch", + "extra_required_fields": ["hs_code", "container_type"], + "mandatory_counterparty_criteria": "Обязательно: клиент; Incoterms; характер груза и ТН ВЭД; адрес забора; адрес доставки; количество и типоразмер контейнеров; вес груза в контейнере.", + "criteria": "1\tНазвание клиента\n2\tУсловия поставки Incoterms\n3\tДата готовности груза\n4\tСтрана, город, адрес забора\n5\tСтоимость груза\n6\tКоличество и типоразмер контейнеров\n7\tВес груза в контейнере\n8\tЕсли нет данных о контейнерах — габариты каждого грузового места (Д×Ш×В) и вес мест\n9\tХарактер груза или наименование, код ТН ВЭД\n10\tБатарейки, газы, жидкости\n11\tMSDS при необходимости\n12\tБатарейки в составе или отдельно\n13\tИз материкового Китая по ж/д — Technical Description of Goods in Railway Transport\n14\tНазвание бренда\n15\tЕсли бренд зарегистрирован в таможенной системе Китая — есть ли у отправителя авторизационное письмо/разрешение на вывоз бренда\n16\tДополнительные сервисы (крепление в контейнере, фитосанитарный контроль и т.п.)\n17\tТребуется ли замена документов\n18\tСтрана, город, точный адрес доставки\n19\tМесто таможенного оформления при экспорте из РФ\n20\tФумигация деревянной тары при экспорте из РФ\n21\tПроверка габаритов грузовых мест на проходимость в дверной проём контейнеров 20DV, 40DV, 40HC\n\nМультимодальная перевозка море + ж/д (FCL): в запросе явно сочетаются морской и ж/д этапы с полным контейнером (интермодаль).", + "keywords": ["море жд fcl", "море+жд fcl", "sea rail fcl", "интермодальн fcl", "контейнер море и жд", "порт станция fcl"], + "confirmation_template": "Dear Partner!\n\nThank you for contacting SEPTEM. We confirm multimodal sea+rail (FCL):\n\n- Route: (Port of loading) / rail -> (Delivery address)\n- Container: (Container type)\n- Weight: (Cargo weight) kg\n- Cargo: (Cargo description)\n- HS: (HS code)\n- MSDS: (MSDS required)\n- Cost: (Estimated cost)\n- Transit: (Transit time)\n\nBest regards,\nSEPTEM Cargo Team", + "info_request_template": "Уважаемый (Клиент)!\n\nДля расчёта мультимодальной перевозки (море + ж/д, FCL) нам необходима следующая информация:\n\n(Необходимая информация)\n\nС уважением,\nКоманда SEPTEM Cargo" + }, + { + "id": 9, + "name": "Авиаперевозка", + "employee_email": "evgeniy.domashnev@timnet.ch, olga.ermakova@timnet.ch, andrey.reshetnyak@timnet.ch, anastasia.fatkina@timnet.ch", + "extra_required_fields": ["hs_code", "stackable_with_others", "total_volume_cbm", "dangerous_goods_clarification", "customs_clearance_place_export_rf"], + "mandatory_counterparty_criteria": "Обязательно: клиент; Incoterms; характер груза и ТН ВЭД; адрес забора; адрес доставки; количество мест, вес и объём; габариты Д×Ш×В; штабелирование с другими отправками; сведения о батарейках, газах, жидкостях, аэрозолях; место таможенного оформления при экспорте из РФ (и прочие пункты чек-листа авиа по полному criteria).", + "criteria": "1\tНазвание клиента\n2\tУсловия поставки Incoterms\n3\tДата готовности груза\n4\tСтрана, город, адрес забора\n5\tСтоимость груза\n6\tКоличество грузовых мест, вес, объём\n7\tГабариты каждого грузового места (Д×Ш×В в см или мм)\n8\tХарактер груза или наименование, код ТН ВЭД\n9\tБатарейки, газы, жидкости, хладоагенты, сухой лёд\n10\tШтабелирование с другими отправками\n11\tШтабелирование мест между собой\n12\tMSDS при химии, батареях, жидкостях, аэрозолях, порошках\n13\tБатарейки — в составе или отдельно\n14\tБатарейки и вылет из материкового Китая — DGM (Dangerous Goods Management) report\n15\tНазвание бренда\n16\tЕсли бренд зарегистрирован в таможенной системе Китая — есть ли у отправителя авторизационное письмо/разрешение на вывоз бренда\n17\tПерелёт через третью страну со сменой авианакладных\n18\tЭкспортная лицензия у отправителя\n19\tДоп. сервис: переупаковка, маркировка, логгеры и т.д.\n20\tКто осуществляет экспедирование в аэропорту прибытия (терминал, сборы авиакомпании)\n21\tСтрана, город, точный адрес доставки\n22\tВывоз из аэропорта — особые требования к автотранспорту, время подачи\n23\tНужно ли таможенное оформление\n24\tМесто таможенного оформления при экспорте из РФ\n25\tФумигация деревянной тары при экспорте из РФ", + "keywords": ["авиа", "авиаперевозка", "самолёт", "air freight", "air cargo", "аэропорт", "flight", "авианакладная", "awb"], + "confirmation_template": "Dear Partner!\n\nThank you for contacting SEPTEM. We confirm air freight:\n\n- Route: (Pickup address) -> (Delivery address)\n- Weight: (Cargo weight) kg\n- Packages: (Number of packages)\n- Volume: (Volume) m3\n- Dimensions: (Dimensions)\n- Cargo: (Cargo description)\n- HS: (HS code)\n- Dangerous goods: (Dangerous properties)\n- MSDS: (MSDS required)\n- DGM (при опасном грузе / вылет из материкового Китая — по правилам перевозчика)\n- Cost: (Estimated cost)\n- Transit: (Transit time)\n\nBest regards,\nSEPTEM Cargo Team", + "info_request_template": "Уважаемый (Клиент)!\n\nДля расчёта авиаперевозки нам необходима следующая информация:\n\n(Необходимая информация)\n\nС уважением,\nКоманда SEPTEM Cargo" + } +]