Баг фикс 1
This commit is contained in:
parent
6cbd981368
commit
671b4c89b7
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue