diff --git a/rag_engine_gemini(1).py b/rag_engine_gemini(1).py index 15bf4be..d2b0819 100644 --- a/rag_engine_gemini(1).py +++ b/rag_engine_gemini(1).py @@ -949,6 +949,65 @@ class RAGEngineGemini: if not deduped: return "" return "; ".join(deduped) + def _enrich_special_requirements(self, shipment: Dict, sources: List[Dict]) -> None: + if not isinstance(shipment, dict): + return + raw_text = self._collect_shipment_source_text(shipment, sources) + if not raw_text.strip(): + return + + # Ключевые паттерны: обязательно содержат слова штрафа / срока / транзита + patterns = [ + # "транзитный срок до 25 дней под зеркальные штрафы 250 USD" + r"(?:транзитн\w*\s+)?срок\w*\s+(?:до\s+)?\d+\s*(?:дн\w*|day\w*).{0,60}?(?:зеркальн\w*\s+)?штраф\w*\s*\d+\s*(?:USD|доллар\w*|руб\w*|EUR)?", + # "зеркальные штрафы 250 USD" + r"зеркальн\w*\s+штраф\w*\s*\d+\s*(?:USD|доллар\w*|руб\w*|EUR)?", + # "штраф за просрочку 100 USD в день" + r"(?:штраф|penalty|неустойк\w*|пени)\s*(?:за\s+)?(?:просрочк\w*|опоздани\w*|срыв\s+срок\w*).{0,100}?\d+\s*(?:USD|доллар\w*|руб\w*|EUR|дн\w*)", + # "срок доставки до 20 дней, иначе штраф 500" + r"срок\s+доставк\w*\s*(?:до\s+)?\d+\s*(?:дн\w*|day\w*).*?(?:штраф|неустойк|пеня|penalty)", + # "срок доставки 25 дней" / "delivery time 25 days" + r"(?:срок|транзитн\w*\s+срок)\s+доставк\w*\s*(?:до\s+)?\d+\s*(?:дн\w*|day\w*)", + r"(?:delivery|transit)\s*time\s*(?:up\s*to\s*)?\d+\s*day\w*", + # "liquidated damages 1000 USD" + r"(?:liquidated\s+damages|ld\s*clause).{0,100}?\d+\s*(?:USD|доллар|руб|EUR)", + # "жёсткий срок до 15.05.2025" + r"доставк\w*\s+(?:строго|жёстко|обязательно)\s+(?:до|к|не\s+позднее)\s+\d{1,2}\.\d{1,2}(?:\.\d{2,4})?", + ] + + found_phrases: List[str] = [] + for pat in patterns: + for m in re.finditer(pat, raw_text, re.IGNORECASE | re.DOTALL): + phrase = re.sub(r'\s+', ' ', m.group(0).strip()) + if phrase not in found_phrases: + found_phrases.append(phrase) + + if not found_phrases: + return + + # Объединяем с уже существующим значением, избегая повторов + existing = (shipment.get("special_transport_requirements") or "").strip() + if existing: + new_parts = [p for p in found_phrases if p.lower() not in existing.lower()] + if new_parts: + shipment["special_transport_requirements"] = existing + " | " + " | ".join(new_parts) + else: + shipment["special_transport_requirements"] = " | ".join(found_phrases) + + # Расширяем доп.сервисы: сроки доставки и штрафные условия должны быть видны отдельным пунктом. + extras = shipment.get("additional_services") + if not isinstance(extras, list): + extras = [] + seen = {str(x).strip().lower() for x in extras if str(x).strip()} + for phrase in found_phrases: + normalized = phrase.strip() + if not normalized: + continue + key = normalized.lower() + if key not in seen: + extras.append(normalized) + seen.add(key) + shipment["additional_services"] = extras def process_shipping_type_criteria(self, criteria_text: str) -> str: """ @@ -1170,8 +1229,11 @@ class RAGEngineGemini: dg = shipment.get("dangerous_goods") if isinstance(dg, dict): - # если вообще ничего не указано → всё в None - if not any(v is True for v in dg.values()): + # если вообще ничего не указано (нет ни True, ни False) → всё в None + # Важно: явные False (non-DG) сохраняем, не затираем. + has_true = any(v is True for v in dg.values()) + has_false = any(v is False for v in dg.values()) + if not has_true and not has_false: shipment["dangerous_goods"] = { "batteries": None, "gases": None, @@ -1221,6 +1283,70 @@ class RAGEngineGemini: # ========================================================= # 6. Контейнеры (морская логика) # ========================================================= + CONTAINER_TYPES_MAP = { + # 20 футов стандарт (20DC / 20GP) + "20DC": [ + "20dc", "20 dc", "20gp", "20 gp", + "20'", "20 ft", "20фт", "20 фут", "20 футов", + "контейнер 20 фут", "контейнер 20 футов", + "20 футовый контейнер", "20-футовый контейнер", + "20 фут / контейнер 20 футов", + "20f", "20 фут контейнер" + ], + + # 40 футов стандарт (40DC / 40GP) + "40DC": [ + "40dc", "40 dc", "40gp", "40 gp", + "40'", "40 ft", "40фт", "40 фут", "40 футов", + "контейнер 40 фут", "контейнер 40 футов", + "40 футовый контейнер", "40-футовый контейнер", + "40f", "40 фут контейнер" + ], + + # 40 футов High Cube + "40HC": [ + "40hc", "40 hc", "40hq", "40 hq", + "high cube", "hc", + "40 фут hc", "40 футов hc", + "40 футов высокий", "высокий контейнер 40", + "40 футов high cube", + "40hc контейнер" + ], + + # 45 футов High Cube + "45HC": [ + "45hc", "45 hc", + "45'", "45 ft", "45 фут", "45 футов", + "контейнер 45 фут", "45 футовый контейнер", + "45 футов high cube" + ], + + # LCL / сборный груз + "LCL": [ + "lcl", "сборный", "сборный груз", + "частичная загрузка", "менее контейнера", + "less than container load" + ], + + # FCL (полный контейнер) + "FCL": [ + "fcl", "полный контейнер", "целый контейнер", + "full container load" + ] +} + + + def detect_container_type(text: str) -> str | None: + text = text.lower() + + for normalized, variants in CONTAINER_TYPES_MAP.items(): + for variant in variants: + if variant in text: + return normalized + + return None + + container = shipment.get("container_type") if not container: @@ -2432,24 +2558,64 @@ class RAGEngineGemini: dg["dry_ice"] = True # Явное "не опасный" / non-DG — проставляем False по пустым категориям. + def _negation_scan(pattern_text: str, keywords: dict) -> None: + """Проверяет текст на наличие 'не содержит...' и выставляет False для совпавших категорий.""" + neg_match = re.search(r'не\s+содержит\s+(?P[^\.;]+)', pattern_text, re.IGNORECASE) + if not neg_match: + return + items = neg_match.group('items').lower() + for cat, word_list in keywords.items(): + if any(w in items for w in word_list): + if dg.get(cat) is None: + dg[cat] = False + + dangerous_keywords = { + "batteries": ["батаре", "аккумулятор", "элемент питания", "batter"], + "gases": ["газ", "аэрозол", "баллон", "gas", "aerosol"], + "liquids": ["жидкост", "liquid", "растворител", "краск", "смол", "масл", "чернил"], + "dry_ice": ["сухого льда", "сухой лёд", "dry ice"] + } + + _negation_scan(low_email, dangerous_keywords) + _negation_scan(low_att, dangerous_keywords) + 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", + r"\bnon[\s\-]?dg\b|not\s+dangerous|not\s+hazardous|" + r"not\s+classified\s+as\s+dangerous|" + r"без\s+опасн\w+\s+груз|" + r"не\s+опасн\w+\s+груз|" + r"груз\s+не\s*опасн\w*|" + r"груз\s+не\s+явля\w+\s+опасн\w*|" + r"не\s+явля\w+\s+опасн\w*|" + r"неопасн\w+\s+груз|" + r"опасн\w+\s+груз\w*\s+нет|" + r"не\s+hazmat", low_email, re.IGNORECASE, )) or (low_att and re.search( - r"\bnon[\s\-]?dg\b|not\s+dangerous|not\s+hazardous|без\s+опасн\w+\s+груз|" - r"не\s+опасн\w+\s+груз|опасн\w+\s+груз\w*\s+нет|не\s+hazmat", + r"\bnon[\s\-]?dg\b|not\s+dangerous|not\s+hazardous|" + r"not\s+classified\s+as\s+dangerous|" + r"без\s+опасн\w+\s+груз|" + r"не\s+опасн\w+\s+груз|" + r"груз\s+не\s*опасн\w*|" + r"груз\s+не\s+явля\w+\s+опасн\w*|" + r"не\s+явля\w+\s+опасн\w*|" + r"неопасн\w+\s+груз|" + r"опасн\w+\s+груз\w*\s+нет|" + r"не\s+hazmat", low_att, re.IGNORECASE, )) ) if explicit_non_dg: for k in ("batteries", "gases", "liquids", "dry_ice"): - if dg.get(k) is None: - dg[k] = False + dg[k] = False + shipment.pop("dangerous_goods_note", None) + # Явное "не опасный груз" приоритетнее: MSDS и DGM отмечаем как не нужные. + shipment["msds_required"] = False + shipment["dgm_report_required"] = False any_true = any(dg.get(k) is True for k in ("batteries", "gases", "liquids", "dry_ice")) if any_true: @@ -2631,6 +2797,8 @@ class RAGEngineGemini: candidates: List[Dict[str, Any]] = [] for ln in lines: ll = ln.lower() + if any(x in ll for x in ["контейнер", "фут", "20ft", "40ft", "40hc", "20dc"]): + continue kind = "unknown" has_gross = bool(gross_re.search(ll)) has_net = bool(net_re.search(ll)) @@ -2684,12 +2852,26 @@ class RAGEngineGemini: 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() + # Строки с явным словом "итого/total" + with_total = [c for c in bucket if c["is_total"]] + + # Для группы "unknown" без явных total: суммируем все веса, + # так как они, скорее всего, относятся к отдельным товарным позициям. + # Контекстные подсказки игнорируем, чтобы не потерять часть позиций. + if bucket_name == "unknown" and not with_total: + chosen = bucket # все подходящие строки + context_matched = False # сигнал, что контекст не привязан к одной партии + else: + chosen = with_total if with_total else bucket + + # Оставляем только записи, где упоминается "kg" или "кг" (если такие есть) + kg_vals = [c for c in chosen if "kg" in c["line"].lower() or "кг" in c["line"].lower()] + if kg_vals: + chosen = kg_vals + vals: List[float] = [] + seen: set[tuple[float, str]] = set() for c in chosen: key = (round(float(c["value"]), 3), str(c["line"]).lower()) if key in seen: @@ -2705,9 +2887,6 @@ class RAGEngineGemini: "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() @@ -2875,6 +3054,7 @@ class RAGEngineGemini: Детерминированное усиление: - пересчитать package_count/weight/volume по множественным упоминаниям (не брать первое) - расширить dimensions всеми вариантами размеров + - применить эвристики отрицаний для булевых полей """ if not isinstance(shipment, dict): return @@ -2887,9 +3067,57 @@ class RAGEngineGemini: 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 _apply_boolean_negations(shipment: Dict, email_text: str): + """Если в тексте явно указано отрицание для булева поля, проставляем False.""" + if not email_text or not isinstance(email_text, str): + return + low = email_text.lower() + + # Явный non-DG: фиксируем все категории как "нет", а MSDS/DGM как "не нужен". + if re.search( + r"\bnon[\s\-]?dg\b|not\s+dangerous|not\s+hazardous|not\s+classified\s+as\s+dangerous|" + r"без\s+опасн\w+\s+груз|не\s+опасн\w+\s+груз|груз\s+не\s*опасн\w*|" + r"груз\s+не\s+явля\w+\s+опасн\w*|не\s+явля\w+\s+опасн\w*|неопасн\w+\s+груз|" + r"опасн\w+\s+груз\w*\s+нет|не\s+hazmat", + low, + re.IGNORECASE, + ): + dg = shipment.get("dangerous_goods") + if not isinstance(dg, dict): + dg = {} + for k in ("batteries", "gases", "liquids", "dry_ice"): + dg[k] = False + shipment["dangerous_goods"] = dg + shipment["msds_required"] = False + shipment["dgm_report_required"] = False + shipment.pop("dangerous_goods_note", None) + + # MSDS + if shipment.get("msds_required") is None: + if re.search(r"msds\s+не\s+(нуж|треб)|не\s+(нуж|треб)\w*\s+msds|паспорт\s+безопасн\w*\s+не\s+(нуж|треб)", low): + shipment["msds_required"] = False + # DGM + if shipment.get("dgm_report_required") is None: + if re.search(r"dgm\s+не\s+(нуж|треб)|не\s+(нуж|треб)\w*\s+dgm|декларац\w+\s+на\s+опасн\w*\s+не\s+(нуж|треб)", low): + shipment["dgm_report_required"] = False + # Замена документов (уже есть отдельный метод, но можно дополнить) + if shipment.get("document_replacement_needed") is None: + if re.search(r"замен\w*\s+документ\w*\s+не\s+(нуж|треб)|не\s+(нуж|треб)\w*\s+замен\w+\s+документ", low): + shipment["document_replacement_needed"] = False + # Авторизационное письмо бренда + if shipment.get("brand_authorization_letter") is None: + if re.search(r"авторизацион\w+\s+письм\w*\s+не\s+(нуж|треб)|не\s+(нуж|треб)\w*\s+авторизацион\w+\s+письм", low): + shipment["brand_authorization_letter"] = False + + _apply_boolean_negations(shipment, raw_text) + + # Далее идёт оригинальный код: определения _parse_existing_num, _maybe_int + def _parse_existing_num(v: Any) -> Optional[float]: if v is None: return None @@ -2955,7 +3183,16 @@ class RAGEngineGemini: 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): + # Не занижаем уже найденный общий вес одиночным "предпочтительным" значением + # (часто это вес одной палеты/позиции без маркера total). + should_apply = ( + existing_w is None + or float(w) >= float(existing_w) * 0.99 + or w_cnt >= 2 + ) + if should_apply and ( + existing_w is None or abs(existing_w - float(w)) > max(1.0, float(w) * 0.01) + ): shipment["total_weight_kg"] = float(w) else: # Для нескольких перевозок обновляем вес только при явной контекстной привязке @@ -3582,15 +3819,24 @@ class RAGEngineGemini: 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) + context_text, sources = self._collect_session_sources(session_id) + + cleaned_emails = _clean_emails_for_learning(sources) + 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], + "input": { + "emails": cleaned_emails, + "context_text": context_text + }, + "output": { + "structured_data": structured_data, + "client_letter": structured_data.get("generated_letter") + }, + "meta": { "session_id": session_id, - "created_at": datetime.now().isoformat(timespec="seconds"), + "created_at": datetime.now().isoformat(), + "quality": "auto" + } } rows.append(row) max_n = _learning_max_store() @@ -3714,6 +3960,8 @@ PRE-FLIGHT: - `shipping_options` заполняй только явно упомянутыми вариантами, без выдуманных цен/сроков. - `document_replacement_needed`: true/false только по явной формулировке, иначе null. - В `dangerous_goods` ставь true/false только при явном подтверждении/отрицании, иначе null. +- Если в письме явно сказано, что груз НЕ опасный (non-DG / not dangerous / не опасный), проставь `dangerous_goods.batteries/gases/liquids/dry_ice = false`, а также `msds_required = false` и `dgm_report_required = false`. +- Если в письме/вложениях есть габариты палет/коробок/грузовых мест — ОБЯЗАТЕЛЬНО заполни `dimensions` даже если также указаны контейнеры/машины/вес. {container_iso_ref_block} ТРЕБУЕМЫЙ ФОРМАТ ОТВЕТА (ТОЛЬКО JSON): @@ -3784,6 +4032,7 @@ document_replacement_needed — только по явным фразам про В ID_emails укажи список ID писем, из которых взята информация для этой перевозки cargo_ready_date — дата готовности груза (ready date, ETD от производителя, «готов к отгрузке с …»): строка или массив строк; не выдумывай. Несколько дат для разных складов/партий — все в массиве или через запятую в одной строке. dimensions — массив объектов, по одному на каждое грузовое место; поля length_cm, width_cm, height_cm — в сантиметрах. Если в письме явно указаны мм (или числа вида 1200×800×600 без «см»), переведи в см или укажи в объекте dimension_unit: \"cm\" | \"mm\" | \"m\" (мм и м код потом приведёт к см). +Если габариты указаны в packing list/аттаче (carton size / pallet size / dimensions), они также обязательны к переносу в `dimensions`. vehicle_dimensions — только габариты транспортного средства (авто), не груза; [] если не указаны shipment_type — "FCL"/"LCL" или "" по правилам выше (не путать с типом перевозки shipping_type) shipping_options — только варианты, явно упомянутые или описанные в письмах/вложениях (цены, сроки — только если указаны). Если в тексте нет вариантов доставки — [] (пустой массив). Не придумывай стоимость, сроки и маршруты. @@ -3840,6 +4089,7 @@ shipping_options — только варианты, явно упомянуты self._infer_brand_and_authorization_from_sources(s, sources) self._infer_document_replacement_from_sources(s, sources) self._infer_dangerous_goods_from_sources(s, sources) + self._enrich_special_requirements(s, sources) # Детерминированно усиливаем числовые поля и габариты по тексту писем/вложений, # чтобы не брать "первое попавшееся" и чтобы размеры извлекались по всем вариантам. @@ -3992,6 +4242,20 @@ shipping_options — только варианты, явно упомянуты return data + def _normalize_shipping_type_name_key(self, name: str) -> str: + """Мягкая нормализация имени типа перевозки для устойчивого сопоставления.""" + if not isinstance(name, str): + return "" + n = name.strip().lower() + if not n: + return "" + # Приводим похожие символы и удаляем пунктуацию/пробелы. + n = n.replace("ё", "е") + n = n.replace(" + ", "+") + n = re.sub(r"[\"'`]", "", n) + n = re.sub(r"[\s\-\u2013\u2014_/.,;:()]+", "", n) + return n + def _shipping_type_record_by_name(self, name: str) -> Optional[Dict]: n = (name or "").strip() if not n: @@ -3999,6 +4263,17 @@ shipping_options — только варианты, явно упомянуты for st in self.shipping_types: if isinstance(st, dict) and st.get("name") == n: return st + key = self._normalize_shipping_type_name_key(n) + if not key: + return None + for st in self.shipping_types: + if not isinstance(st, dict): + continue + st_name = st.get("name") + if not isinstance(st_name, str): + continue + if self._normalize_shipping_type_name_key(st_name) == key: + return st return None def _normalize_requested_shipping_type_names(self, raw: Any) -> List[str]: @@ -4015,21 +4290,34 @@ shipping_options — только варианты, явно упомянуты known = { st.get("name") for st in self.shipping_types - if isinstance(st, dict) and st.get("name") + if isinstance(st, dict) and isinstance(st.get("name"), str) and st.get("name") } + known_by_key: Dict[str, str] = {} + for st_name in known: + k = self._normalize_shipping_type_name_key(st_name) + if k and k not in known_by_key: + known_by_key[k] = st_name out: List[str] = [] seen: set[str] = set() for x in items: if not isinstance(x, str): continue n = x.strip() - if not n or n not in known: + if not n: + continue + resolved = n + if resolved not in known: + nk = self._normalize_shipping_type_name_key(resolved) + mapped = known_by_key.get(nk) if nk else None + if mapped: + resolved = mapped + if resolved not in known: if n: logger.warning("requested_shipping_type_names: неизвестное имя %r — пропуск", n) continue - if n not in seen: - seen.add(n) - out.append(n) + if resolved not in seen: + seen.add(resolved) + out.append(resolved) return out def _build_candidates_from_type_names(self, names: List[str]) -> List[Dict]: @@ -4340,7 +4628,7 @@ shipping_options — только варианты, явно упомянуты if yes: data["dangerous_goods_str"] = "Да: " + ", ".join(yes) elif len(explicit_no) == len(dg_keys): - data["dangerous_goods_str"] = "Нет (по всем категориям в перечне)" + data["dangerous_goods_str"] = "Нет" elif explicit_no: data["dangerous_goods_str"] = ( "Нет: " + ", ".join(explicit_no) + " — по остальным категориям информации нет" @@ -4553,6 +4841,7 @@ shipping_options — только варианты, явно упомянуты default_type = shipping_types[0] + section_idx = 0 for i, shipment in enumerate(valid_shipments, start=1): required_fields = [ "client_name", "incoterms", "pickup_address", "cargo_value", @@ -4613,8 +4902,9 @@ shipping_options — только варианты, явно упомянуты signature = sig section_body = body if body else _clean_text(letter_one) + section_idx += 1 sections.append( - f"Перевозка ({i}) — {type_name}\n\n{section_body}".strip() + f"Перевозка ({section_idx}) — {type_name}\n\n{section_body}".strip() ) combined = "\n\n".join(sections).strip() @@ -4627,11 +4917,28 @@ shipping_options — только варианты, явно упомянуты Для каждой перевозки укажи все доступные детали: маршрут, характеристики груза, особые условия, требуемые документы и предложи варианты доставки. """ - + def _clean_emails_for_learning(emails): + cleaned = [] + for e in emails: + e = dict(e) + + # удаляем base64 + for att in e.get("attachments", []): + att.pop("content_base64", None) + + # режем тело письма + if isinstance(e.get("body"), str): + e["body"] = e["body"][:5000] + + cleaned.append(e) + return cleaned + context_text, sources = self._collect_session_sources(session_id) result = await self.query_cargo_info(query, session_id, top_k=50) letter = self._generate_response_letter(result.get('structured_data', {})) result['generated_letter'] = letter result = self._normalize_api_report_payload(result) + result['context_text'] = context_text + result['emails'] = sources if session_id: self._save_report_to_file(session_id, result) return result