Баг фикс 1

This commit is contained in:
m.milnikov 2026-04-26 20:47:45 +00:00
parent 6cbd981368
commit 671b4c89b7
1 changed files with 338 additions and 31 deletions

View File

@ -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<items>[^\.;]+)', pattern_text, re.IGNORECASE)
if not neg_match:
return
items = neg_match.group('items').lower()
for cat, word_list in keywords.items():
if any(w in items for w in word_list):
if dg.get(cat) is None:
dg[cat] = False
dangerous_keywords = {
"batteries": ["батаре", "аккумулятор", "элемент питания", "batter"],
"gases": ["газ", "аэрозол", "баллон", "gas", "aerosol"],
"liquids": ["жидкост", "liquid", "растворител", "краск", "смол", "масл", "чернил"],
"dry_ice": ["сухого льда", "сухой лёд", "dry ice"]
}
_negation_scan(low_email, dangerous_keywords)
_negation_scan(low_att, dangerous_keywords)
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