Баг фикс 1
This commit is contained in:
parent
6cbd981368
commit
671b4c89b7
|
|
@ -949,6 +949,65 @@ class RAGEngineGemini:
|
||||||
if not deduped:
|
if not deduped:
|
||||||
return ""
|
return ""
|
||||||
return "; ".join(deduped)
|
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:
|
def process_shipping_type_criteria(self, criteria_text: str) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
@ -1170,8 +1229,11 @@ class RAGEngineGemini:
|
||||||
dg = shipment.get("dangerous_goods")
|
dg = shipment.get("dangerous_goods")
|
||||||
|
|
||||||
if isinstance(dg, dict):
|
if isinstance(dg, dict):
|
||||||
# если вообще ничего не указано → всё в None
|
# если вообще ничего не указано (нет ни True, ни False) → всё в None
|
||||||
if not any(v is True for v in dg.values()):
|
# Важно: явные 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"] = {
|
shipment["dangerous_goods"] = {
|
||||||
"batteries": None,
|
"batteries": None,
|
||||||
"gases": None,
|
"gases": None,
|
||||||
|
|
@ -1221,6 +1283,70 @@ class RAGEngineGemini:
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# 6. Контейнеры (морская логика)
|
# 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")
|
container = shipment.get("container_type")
|
||||||
|
|
||||||
if not container:
|
if not container:
|
||||||
|
|
@ -2432,24 +2558,64 @@ class RAGEngineGemini:
|
||||||
dg["dry_ice"] = True
|
dg["dry_ice"] = True
|
||||||
|
|
||||||
# Явное "не опасный" / non-DG — проставляем False по пустым категориям.
|
# Явное "не опасный" / 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(
|
explicit_non_dg = bool(
|
||||||
(low_email and re.search(
|
(low_email and re.search(
|
||||||
r"\bnon[\s\-]?dg\b|not\s+dangerous|not\s+hazardous|без\s+опасн\w+\s+груз|"
|
r"\bnon[\s\-]?dg\b|not\s+dangerous|not\s+hazardous|"
|
||||||
r"не\s+опасн\w+\s+груз|опасн\w+\s+груз\w*\s+нет|не\s+hazmat",
|
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,
|
low_email,
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)) or
|
)) or
|
||||||
(low_att and re.search(
|
(low_att and re.search(
|
||||||
r"\bnon[\s\-]?dg\b|not\s+dangerous|not\s+hazardous|без\s+опасн\w+\s+груз|"
|
r"\bnon[\s\-]?dg\b|not\s+dangerous|not\s+hazardous|"
|
||||||
r"не\s+опасн\w+\s+груз|опасн\w+\s+груз\w*\s+нет|не\s+hazmat",
|
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,
|
low_att,
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
if explicit_non_dg:
|
if explicit_non_dg:
|
||||||
for k in ("batteries", "gases", "liquids", "dry_ice"):
|
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"))
|
any_true = any(dg.get(k) is True for k in ("batteries", "gases", "liquids", "dry_ice"))
|
||||||
if any_true:
|
if any_true:
|
||||||
|
|
@ -2631,6 +2797,8 @@ class RAGEngineGemini:
|
||||||
candidates: List[Dict[str, Any]] = []
|
candidates: List[Dict[str, Any]] = []
|
||||||
for ln in lines:
|
for ln in lines:
|
||||||
ll = ln.lower()
|
ll = ln.lower()
|
||||||
|
if any(x in ll for x in ["контейнер", "фут", "20ft", "40ft", "40hc", "20dc"]):
|
||||||
|
continue
|
||||||
kind = "unknown"
|
kind = "unknown"
|
||||||
has_gross = bool(gross_re.search(ll))
|
has_gross = bool(gross_re.search(ll))
|
||||||
has_net = bool(net_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]
|
bucket = [c for c in candidates if c["kind"] == bucket_name]
|
||||||
if not bucket:
|
if not bucket:
|
||||||
continue
|
continue
|
||||||
# Внутри bucket предпочитаем строки с "итого/total".
|
|
||||||
|
# Строки с явным словом "итого/total"
|
||||||
with_total = [c for c in bucket if c["is_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
|
chosen = with_total if with_total else bucket
|
||||||
|
|
||||||
seen: set[tuple[float, str]] = set()
|
# Оставляем только записи, где упоминается "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] = []
|
vals: List[float] = []
|
||||||
|
seen: set[tuple[float, str]] = set()
|
||||||
for c in chosen:
|
for c in chosen:
|
||||||
key = (round(float(c["value"]), 3), str(c["line"]).lower())
|
key = (round(float(c["value"]), 3), str(c["line"]).lower())
|
||||||
if key in seen:
|
if key in seen:
|
||||||
|
|
@ -2705,9 +2887,6 @@ class RAGEngineGemini:
|
||||||
"priority": bucket_name,
|
"priority": bucket_name,
|
||||||
"context_matched": context_matched,
|
"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:
|
def _line_looks_vehicle_size_context(self, line: str) -> bool:
|
||||||
"""Строка про габариты транспорта/машины — не смешивать с габаритами груза."""
|
"""Строка про габариты транспорта/машины — не смешивать с габаритами груза."""
|
||||||
ll = line.lower()
|
ll = line.lower()
|
||||||
|
|
@ -2875,6 +3054,7 @@ class RAGEngineGemini:
|
||||||
Детерминированное усиление:
|
Детерминированное усиление:
|
||||||
- пересчитать package_count/weight/volume по множественным упоминаниям (не брать первое)
|
- пересчитать package_count/weight/volume по множественным упоминаниям (не брать первое)
|
||||||
- расширить dimensions всеми вариантами размеров
|
- расширить dimensions всеми вариантами размеров
|
||||||
|
- применить эвристики отрицаний для булевых полей
|
||||||
"""
|
"""
|
||||||
if not isinstance(shipment, dict):
|
if not isinstance(shipment, dict):
|
||||||
return
|
return
|
||||||
|
|
@ -2887,9 +3067,57 @@ class RAGEngineGemini:
|
||||||
triplet_sum = quantities.get("triplet_sum") or None
|
triplet_sum = quantities.get("triplet_sum") or None
|
||||||
separate_sum = quantities.get("separate_sum") or {}
|
separate_sum = quantities.get("separate_sum") or {}
|
||||||
preferred_weight = self._extract_preferred_weight_kg(raw_text, shipment)
|
preferred_weight = self._extract_preferred_weight_kg(raw_text, shipment)
|
||||||
|
|
||||||
total_shipments = getattr(self, "_postprocess_total_shipments", None)
|
total_shipments = getattr(self, "_postprocess_total_shipments", None)
|
||||||
single_shipment_mode = isinstance(total_shipments, int) and total_shipments == 1
|
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]:
|
def _parse_existing_num(v: Any) -> Optional[float]:
|
||||||
if v is None:
|
if v is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -2955,7 +3183,16 @@ class RAGEngineGemini:
|
||||||
w_cnt = int(preferred_weight.get("count") or 0)
|
w_cnt = int(preferred_weight.get("count") or 0)
|
||||||
if w is not None and w_cnt >= 1:
|
if w is not None and w_cnt >= 1:
|
||||||
existing_w = _parse_existing_num(shipment.get("total_weight_kg"))
|
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)
|
shipment["total_weight_kg"] = float(w)
|
||||||
else:
|
else:
|
||||||
# Для нескольких перевозок обновляем вес только при явной контекстной привязке
|
# Для нескольких перевозок обновляем вес только при явной контекстной привязке
|
||||||
|
|
@ -3582,15 +3819,24 @@ class RAGEngineGemini:
|
||||||
if any(isinstance(r, dict) and r.get("dedupe_hash") == h for r in rows):
|
if any(isinstance(r, dict) and r.get("dedupe_hash") == h for r in rows):
|
||||||
logger.info("Cargo learning: duplicate hash, skip")
|
logger.info("Cargo learning: duplicate hash, skip")
|
||||||
return False
|
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 = {
|
row = {
|
||||||
"dedupe_hash": h,
|
"input": {
|
||||||
"context_preview": ctx[:20000],
|
"emails": cleaned_emails,
|
||||||
"context_words": sorted(_context_word_set(ctx[:12000]))[:500],
|
"context_text": context_text
|
||||||
"structured_data_compact": compact,
|
},
|
||||||
"notes": (notes or "")[:2000],
|
"output": {
|
||||||
|
"structured_data": structured_data,
|
||||||
|
"client_letter": structured_data.get("generated_letter")
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"created_at": datetime.now().isoformat(timespec="seconds"),
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"quality": "auto"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
max_n = _learning_max_store()
|
max_n = _learning_max_store()
|
||||||
|
|
@ -3714,6 +3960,8 @@ PRE-FLIGHT:
|
||||||
- `shipping_options` заполняй только явно упомянутыми вариантами, без выдуманных цен/сроков.
|
- `shipping_options` заполняй только явно упомянутыми вариантами, без выдуманных цен/сроков.
|
||||||
- `document_replacement_needed`: true/false только по явной формулировке, иначе null.
|
- `document_replacement_needed`: true/false только по явной формулировке, иначе null.
|
||||||
- В `dangerous_goods` ставь 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}
|
{container_iso_ref_block}
|
||||||
|
|
||||||
ТРЕБУЕМЫЙ ФОРМАТ ОТВЕТА (ТОЛЬКО JSON):
|
ТРЕБУЕМЫЙ ФОРМАТ ОТВЕТА (ТОЛЬКО JSON):
|
||||||
|
|
@ -3784,6 +4032,7 @@ document_replacement_needed — только по явным фразам про
|
||||||
В ID_emails укажи список ID писем, из которых взята информация для этой перевозки
|
В ID_emails укажи список ID писем, из которых взята информация для этой перевозки
|
||||||
cargo_ready_date — дата готовности груза (ready date, ETD от производителя, «готов к отгрузке с …»): строка или массив строк; не выдумывай. Несколько дат для разных складов/партий — все в массиве или через запятую в одной строке.
|
cargo_ready_date — дата готовности груза (ready date, ETD от производителя, «готов к отгрузке с …»): строка или массив строк; не выдумывай. Несколько дат для разных складов/партий — все в массиве или через запятую в одной строке.
|
||||||
dimensions — массив объектов, по одному на каждое грузовое место; поля length_cm, width_cm, height_cm — в сантиметрах. Если в письме явно указаны мм (или числа вида 1200×800×600 без «см»), переведи в см или укажи в объекте dimension_unit: \"cm\" | \"mm\" | \"m\" (мм и м код потом приведёт к см).
|
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 — только габариты транспортного средства (авто), не груза; [] если не указаны
|
vehicle_dimensions — только габариты транспортного средства (авто), не груза; [] если не указаны
|
||||||
shipment_type — "FCL"/"LCL" или "" по правилам выше (не путать с типом перевозки shipping_type)
|
shipment_type — "FCL"/"LCL" или "" по правилам выше (не путать с типом перевозки shipping_type)
|
||||||
shipping_options — только варианты, явно упомянутые или описанные в письмах/вложениях (цены, сроки — только если указаны). Если в тексте нет вариантов доставки — [] (пустой массив). Не придумывай стоимость, сроки и маршруты.
|
shipping_options — только варианты, явно упомянутые или описанные в письмах/вложениях (цены, сроки — только если указаны). Если в тексте нет вариантов доставки — [] (пустой массив). Не придумывай стоимость, сроки и маршруты.
|
||||||
|
|
@ -3840,6 +4089,7 @@ shipping_options — только варианты, явно упомянуты
|
||||||
self._infer_brand_and_authorization_from_sources(s, sources)
|
self._infer_brand_and_authorization_from_sources(s, sources)
|
||||||
self._infer_document_replacement_from_sources(s, sources)
|
self._infer_document_replacement_from_sources(s, sources)
|
||||||
self._infer_dangerous_goods_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
|
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]:
|
def _shipping_type_record_by_name(self, name: str) -> Optional[Dict]:
|
||||||
n = (name or "").strip()
|
n = (name or "").strip()
|
||||||
if not n:
|
if not n:
|
||||||
|
|
@ -3999,6 +4263,17 @@ shipping_options — только варианты, явно упомянуты
|
||||||
for st in self.shipping_types:
|
for st in self.shipping_types:
|
||||||
if isinstance(st, dict) and st.get("name") == n:
|
if isinstance(st, dict) and st.get("name") == n:
|
||||||
return st
|
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
|
return None
|
||||||
|
|
||||||
def _normalize_requested_shipping_type_names(self, raw: Any) -> List[str]:
|
def _normalize_requested_shipping_type_names(self, raw: Any) -> List[str]:
|
||||||
|
|
@ -4015,21 +4290,34 @@ shipping_options — только варианты, явно упомянуты
|
||||||
known = {
|
known = {
|
||||||
st.get("name")
|
st.get("name")
|
||||||
for st in self.shipping_types
|
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] = []
|
out: List[str] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for x in items:
|
for x in items:
|
||||||
if not isinstance(x, str):
|
if not isinstance(x, str):
|
||||||
continue
|
continue
|
||||||
n = x.strip()
|
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:
|
if n:
|
||||||
logger.warning("requested_shipping_type_names: неизвестное имя %r — пропуск", n)
|
logger.warning("requested_shipping_type_names: неизвестное имя %r — пропуск", n)
|
||||||
continue
|
continue
|
||||||
if n not in seen:
|
if resolved not in seen:
|
||||||
seen.add(n)
|
seen.add(resolved)
|
||||||
out.append(n)
|
out.append(resolved)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def _build_candidates_from_type_names(self, names: List[str]) -> List[Dict]:
|
def _build_candidates_from_type_names(self, names: List[str]) -> List[Dict]:
|
||||||
|
|
@ -4340,7 +4628,7 @@ shipping_options — только варианты, явно упомянуты
|
||||||
if yes:
|
if yes:
|
||||||
data["dangerous_goods_str"] = "Да: " + ", ".join(yes)
|
data["dangerous_goods_str"] = "Да: " + ", ".join(yes)
|
||||||
elif len(explicit_no) == len(dg_keys):
|
elif len(explicit_no) == len(dg_keys):
|
||||||
data["dangerous_goods_str"] = "Нет (по всем категориям в перечне)"
|
data["dangerous_goods_str"] = "Нет"
|
||||||
elif explicit_no:
|
elif explicit_no:
|
||||||
data["dangerous_goods_str"] = (
|
data["dangerous_goods_str"] = (
|
||||||
"Нет: " + ", ".join(explicit_no) + " — по остальным категориям информации нет"
|
"Нет: " + ", ".join(explicit_no) + " — по остальным категориям информации нет"
|
||||||
|
|
@ -4553,6 +4841,7 @@ shipping_options — только варианты, явно упомянуты
|
||||||
|
|
||||||
default_type = shipping_types[0]
|
default_type = shipping_types[0]
|
||||||
|
|
||||||
|
section_idx = 0
|
||||||
for i, shipment in enumerate(valid_shipments, start=1):
|
for i, shipment in enumerate(valid_shipments, start=1):
|
||||||
required_fields = [
|
required_fields = [
|
||||||
"client_name", "incoterms", "pickup_address", "cargo_value",
|
"client_name", "incoterms", "pickup_address", "cargo_value",
|
||||||
|
|
@ -4613,8 +4902,9 @@ shipping_options — только варианты, явно упомянуты
|
||||||
signature = sig
|
signature = sig
|
||||||
|
|
||||||
section_body = body if body else _clean_text(letter_one)
|
section_body = body if body else _clean_text(letter_one)
|
||||||
|
section_idx += 1
|
||||||
sections.append(
|
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()
|
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)
|
result = await self.query_cargo_info(query, session_id, top_k=50)
|
||||||
letter = self._generate_response_letter(result.get('structured_data', {}))
|
letter = self._generate_response_letter(result.get('structured_data', {}))
|
||||||
result['generated_letter'] = letter
|
result['generated_letter'] = letter
|
||||||
result = self._normalize_api_report_payload(result)
|
result = self._normalize_api_report_payload(result)
|
||||||
|
result['context_text'] = context_text
|
||||||
|
result['emails'] = sources
|
||||||
if session_id:
|
if session_id:
|
||||||
self._save_report_to_file(session_id, result)
|
self._save_report_to_file(session_id, result)
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue