/
Paff
/
declarant
Обзор
Документация
Войти
/
Paff
/
declarant
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
web/ai_parser.py
1 024 строки
55 KB
1pash1985-gif
chore: commit deployed source changes before Yandex OCR deploy
09 авг 2026, 23:56
09 авг 2026, 23:56
9777dcd
Код
Авторство
О чём код?
""" AI-парсер документов через DeepSeek / Kimi K3 API. Использует OpenAI-совместимый API для извлечения структурированных данных из текста инвойсов и упаковочных листов. """ import json import logging import os import re from openai import OpenAI from config import ( DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL, KIMI_API_KEY, KIMI_BASE_URL, ) logger = logging.getLogger(__name__) # Таймаут для API-вызовов (сек). Kimi часто зависает на больших текстах. # Дефолт 180 (было 120) — для заполнения таблиц (много позиций) нужно больше времени. AI_TIMEOUT = int(os.environ.get('AI_TIMEOUT', '180')) # Клиент DeepSeek (OpenAI-совместимый) # max_retries=0: retry logic is handled in ai_ensemble._call_model client = OpenAI( api_key=DEEPSEEK_API_KEY, base_url=DEEPSEEK_BASE_URL, timeout=AI_TIMEOUT, max_retries=0, ) # Клиент Kimi K3 (Moonshot, OpenAI-совместимый) kimi_client = OpenAI( api_key=KIMI_API_KEY, base_url=KIMI_BASE_URL, timeout=AI_TIMEOUT, max_retries=0, ) KIMI_MODEL = 'kimi-k3' def _clean_ai_json_response(content: str) -> str: """Убираем markdown-обёртки и лишний текст вокруг JSON-ответа модели. DeepSeek иногда возвращает ``` ```json ...``` ``` или добавляет пояснительный текст до/после блока JSON. """ if not content: return content content = content.strip() # Снимаем ```json / ``` fenced-блоки if content.startswith('```'): # первая строка вида ```json content = re.sub(r'^```[a-zA-Z]*\s*\n?', '', content) # закрывающие ``` if content.endswith('```'): content = content[:-3] content = content.strip() # Если модель влепила текст до JSON — вырежем от первой { до последней } first = content.find('{') last = content.rfind('}') if first != -1 and last != -1 and last > first: content = content[first:last + 1] return content.strip() def _try_repair_json(content: str): """Пытается распарсить повреждённый ответ модели (обрыв / мусор после JSON). Возвращает объект или None, если восстановить не удалось. """ # 1) Мусор после JSON (второй объект, пояснения) — берём первый полный объект try: obj, _ = json.JSONDecoder().raw_decode(content.lstrip()) return obj except Exception: pass # 2) Обрезанный ответ — отступаем к последнему завершённому элементу # и закрываем незакрытые строки/скобки s = content for _ in range(50): stack: list[str] = [] in_str = esc = False for ch in s: if in_str: if esc: esc = False elif ch == '\\': esc = True elif ch == '"': in_str = False elif ch == '"': in_str = True elif ch in '{[': stack.append(ch) elif ch in '}]' and stack: stack.pop() cand = s + ('"' if in_str else '') cand = re.sub(r'[,\s]+$', '', cand) cand += ''.join('}' if c == '{' else ']' for c in reversed(stack)) try: return json.loads(cand) except Exception: pass # отступаем до предыдущей запятой / открывающей скобки cut = max(s.rfind(','), s.rfind('{'), s.rfind('[')) if cut <= 0: return None s = s[:cut] return None def _loads_ai_json(content: str) -> dict: """json.loads с автоматическим восстановлением повреждённого JSON. Бросает json.JSONDecodeError, если восстановить не удалось. """ try: return json.loads(content) except json.JSONDecodeError: repaired = _try_repair_json(content) if isinstance(repaired, dict) and repaired: logger.warning('JSON от модели повреждён — восстановлен автоматически') repaired['_json_repaired'] = True return repaired raise INVOICE_SYSTEM_PROMPT = """\ Ты — эксперт по парсингу коммерческих инвойсов на цветы (импорт). Тебе дан текст, извлечённый из PDF-инвойса или Excel-таблицы. Извлеки из него структурированные данные. Верни ТОЛЬКО валидный JSON (без markdown, без ```), строго по схеме: { "supplier": "поставщик / плантация (grower/farm)", "marking": "маркировка — КОРОТКИЙ КОД клиента-получателя,\n напр. B-AQUA, B-AIGUL, B-FIT, K-BO, ARSK, NEFT, RASUL, TSVETY, BOSS, LA LILA, STAN, DRIVK.\n См. ПРАВИЛА ниже — если в документе нет такого кода, верни null", "invoice_number": "номер инвойса", "invoice_date": "дата (как в документе)", "country": "страна отправки (2-буквенный код ISO, например EC, NL, KE, CO)", "client": "НЕ ЗАПОЛНЯЙ — всегда null (грузополучатель EFLOWERS B.V./аналог — НЕ маркировка)", "awb": "номер авианакладной (если есть)", "items": [ { "name": "полное название цветка/растения как в инвойсе", "flower_type": "тип (Rose, Carnation, Chrysanthemum, Gypsophila и т.д.)", "variety": "сорт (если указан)", "stems": количество_стеблей_число, "boxes": количество_коробок_число_или_дробь, "unit_price": цена_за_единицу_число, "total_price": общая_сумма_число, "length_cm": длина_стебля_см_число_или_null, "marking": "маркировка позиции (если разная по строкам) — КОРОТКИЙ КОД", "plantation": "плантация позиции (если разная по строкам)" } ], "total_stems": общее_количество_стеблей, "total_boxes": общее_количество_FULL_коробок_в_дробном_виде, "total_amount": общая_сумма_инвойса, "currency": "валюта (USD, EUR и т.д.)" } === ТИПЫ КОРОБОК И КОНВЕРТАЦИЯ (КРИТИЧНО!) === FB (Full Box) = 1.0 | HB (Half Box) = 0.5 | QB (Quarter Box) = 0.25 Поле total_boxes ВСЕГДА в Full Boxes (дробное число). Примеры: 1 HB = 0.5 | 3 HB = 1.5 | 1 QB = 0.25 | 5 Half = 2.5 | 7 HB = 3.5. В поле items[].boxes указывай КОЛИЧЕСТВО физических мест (штук коробок), а в total_boxes — сумму в Full Boxes. Нотация «1hb/0.50full» → 1 физ. место, 0.50 full boxes. Нотация «1QB/0.25FULL» → 1 физ. место, 0.25 full boxes. === ПРАВИЛА МАРКИРОВКИ (КРИТИЧНО ВАЖНО) === Маркировка — это короткий алфавитно-цифровой КОД (обычно 2–15 символов), обозначающий конечного клиента, которому адресована партия. Примеры валидных маркировок: B-AQUA, B-AIGUL, B-FIT, B-KAZAN, B-ORHIDEYA, B-ANNA.MELANI, B-DRIVK, K-BO, NERO, ARSK, RASUL, MJ, MOSCOWFLOR, TSVETY, OM, KIRO, FPARK, ELA, SAMARA, OKTY, RONC, BOSS, LA LILA, STAN, ISHAIN, DLG, MAGNER, ALMAZ, ROM SK, DOLFLOR, VALDAYDARI, SOFSAR. Где искать маркировку (ПРИОРИТЕТ сверху вниз): 1) Поле «MARK:», «Маркировка:», «Mark:», «Marking:» в шапке документа. 2) Поле «Customer:», «Клиент:» (если это короткий код, а НЕ юр. лицо). 3) Поле «Consignee:» / «Consignee Name» (если это короткий код типа B.BOSS, .DRIVK, LA LILA — а НЕ полное название юр. лица). 4) Поле «Marketing Name» (если короткий код). 5) Поле «Договор клиент: ECU - XXXXX» → XXXXX = маркировка. 6) В строках товаров в колонках: «PO», «Cust.», «Customer», «Ref», «Reference», «Mark», «Marking», «Client Mark», «Consignee Mark», «МАРКА», «КОД», «Special Pack», «CUST ORD». ОСОБЫЕ СЛУЧАИ маркировки: • Consignee «B.BOSS» → marking = «BOSS» (убери точку-префикс «B.») • Consignee «.DRIVK» → marking = «DRIVK» (убери точку-префикс) • Если маркировка начинается с «B-» — это НОРМАЛЬНО, оставь как есть. ЧТО НЕ ЯВЛЯЕТСЯ МАРКИРОВКОЙ (верни null, если нашёл только это): ✗ Грузополучатель в Нидерландах с суффиксом B.V., N.V., LTD, GMBH. Примеры: «EFLOWERS B.V.», «BLOOMS EUROPE B.V.», «Biflorica Marketplace Corp». ✗ Название поставщика/фермы с суффиксом S.A.S., S.A., INC., CIA. LTDA. Примеры: «GLOW FLOWERS SAS», «LA CONCHITA», «SHALIMAR FLOWERS LTD». ✗ Имена транспортных агентов: «BEST FLORA TRUCK», «PACIFIC CARGO», «EBF CARGO», «FRESH LOGISTICS», «DYC EXPORT», «INTERNATIONAL FLOWERS CARGO SAS». ✗ Адреса, города, страны, аэропорты. ✗ Слова FLOWERS, FLORA, IMPORT, EXPORT, GROUP, COMPANY, TRADING в сочетаниях. ✗ Клиент «LUXUS» сам по себе — это агент, маркировка ПОСЛЕ него (LUXUS STAN → STAN). ЕСЛИ НЕ НАШЁЛ в документе короткий код — верни «marking»: null. === РАСПОЗНАВАНИЕ ФОРМАТОВ ИНВОЙСОВ === Формат 1: SJ Flowers / Condor Andino / SURANDINA (Колумбия, PDF) Нотация стеблей: «4,00QBx35» = 4 QB × 35 stems = 140 stems, boxes=4 (QB=0.25 каждый → 1.0 full). PO / CUST ORD = маркировка (B-ANNA.MELANI, B-BEDILO). Формат 2: PARADISE FLORAL / Gardaexport (Эквадор, PDF) «1 HB PINK FLOYD 50cm 25 18 450» = 1 HB (0.5 full), variety=PINK FLOYD, 50cm, 450 stems. Consignee = маркировка (B.BOSS → BOSS). Формат 3: VINTAGE ROSES / GALAXYTRADE (Эквадор, PDF) Тип «Half ok» = HB (0.5). Строки: stems ST Variety Length. Поля «.DOM ROZ PL:» (пакинг), MARK (на стр.) = маркировка. Consignee = маркировка (напр. .DRIVK → DRIVK). Формат 4: ANNAMA ROSES / FLOUER CASTL (Эквадор, PDF) «Marketing Name: FLOUER CASTL», Consignee «LA LILA» = маркировка. Строки: «UIO 1 HB EXPLORER 70 Cm ... Units/Box T.Units». Total Full box = total_boxes. Формат 5: LUXUSBLUMEN SA (Эквадор, PDF) «Customer: STAN» = маркировка (STAN). «LUXUS STAN» — LUXUS это агент. BOX TYPE: «HALF» = 0.5 full. Строки: ORDER BOX CODE BX VARIETIES CM. Формат 6: ECUAROSES / подобные (Эквадор, XLS) Шапка: «MARK: ISHAIN», «AWB: 235-7834 2283». Строки: BOX/PCS | Farm | Stems | VARIETY | размеры по длинам (40/50/60/70/80/90/100/120). Значение в колонке размера = кол-во пачек; пачка × stems_per_bunch = stems. Формат 7: AstraFund Holland BV (Голландия, XLS) «Code: ARALIA» = маркировка. «Colli: 3» = коробки. Строки: Quantity | Product | S1(=cm) | S2 | Price | Amount | Box# | Box. Формат 8: eFlowers/BESTFLORA (Голландия, XLSX) Строка R17: «BESTFLORA-1» + «b-ORHIDEYA» = маркировка B-ORHIDEYA. Строки: Boxes | Description | Grower | cm | Gram | Total stems | Price | Value. Формат 9: GOLDMAN LM / Dutch auction (Голландия, XLS) «Factuur nr: 202604398 / ANKA» → маркировка = ANKA. Строки: Kolli | Inh. | Aantal | Omschrijving | Grower | S1 | S2 | Fc | Price | Amount. Итого внизу: Kolli | Netto KG | Total stems | Total amount. Формат 10: eFlowers B.V. (Голландия, PDF-инвойс) Якоря: «eFlowers B.V.» + «Export box» / «Total boxes loaded». Итоговая строка: «06031400 Chrysanthemum 2280 209.6 224.6 1240.00» = HS-код(8 цифр), Тип цветка, Всего стеблей, Нетто, Брутто, Сумма. Детальные строки: «6 CHR T ALTAJ 100g Linflowers 70 100 840 Export box ...» = Boxes, Описание, Грувер, Длина, Вес, Стебли, «Export box». «Total boxes loaded: 15» → total_boxes = 15. Формат 11: R&M Roses / мелкие фермы Эквадора (PDF) Якоря: «Farm Information», «Variety», числа в формате «50cm»/«70cm». Строки: «1 H PINK FLOYD 50cm 25 16 400 0.17 68.00» = Boxes, BoxType(H=Half/Q=Quarter/F=Full/E=Eighth), Сорт, ДлинаCm, ШтВПучке, Пучки, Стебли, PVP, Amount. H=0.5 full, Q=0.25 full, F=1.0 full, E=0.125 full. Группируй розы по длине: все розы 50cm → «ROSA 50», 60cm → «ROSA 60» и т.д. Формат 12: COMMERCIAL INVOICE Ecuador (Sberbank/Panatlantic, PDF) Якоря: «COMMERCIAL INVOICE», «HTS» (HS-код), «TOTAL PIECES». Секция «Single Flowers»: научная классификация. Строки: «Gypsophila paniculata Gypsophila Gypsophila 1 450 0.32 144.00» = Род Вид ОбщееИмя Тип Штук Стебли Цена Сумма. Тип определяется по Genus (Gypsophila, Rosa, Dianthus). Формат 13: Cantiza Floral Group (Эквадор, PDF) Якорь: «CANTIZA FLORAL». Строки: «1 HB CAN-120X32X30.8 EXPLORER 50CM N 25ST CZ ROSES C2 12 $6.2500 300 $0.2500 $75.00» = Boxes BoxType BoxCode Сорт ДлинаCM Режим ШтВПучке Код Тип Ферма Пучки Цена Стебли Цена/шт Сумма. Итого: «1 TOTALS 12 300 $75.00» = Boxes, -, Bunches, TotalStems, Amount. SPECIES колонка: ROSES=Rose, CARNATION=Carnation. Формат 14: SOJANMI SPRINGFIELDS / Amor and Omang (Кения, PDF) Якорь: «SOJANMI» в тексте. Строки: «1 70101940 mix inA Box;Stem length 35 4 600 2,400 0.080 0.00 192.00 B-SKLADULI» = SN, ItemCode(8 цифр), Описание, Boxes, PackRate, Quantity(=stems), PVP, Tax, Total, FarmCode. Описание может содержать «;Stem length NN» — отбрось это (не часть названия). QUANTITY = реальное количество стеблей. Последнее поле = маркировка (B-SKLADULI и т.д.). === ГРУППИРОВКА РОЗ ПО ДЛИНЕ (ВАЖНО) === Для роз name ДОЛЖНО содержать длину стебля, если она указана в документе. Формат: «ROSA 50», «ROSA 60», «ROSA 70» и т.д. (округляй до ближайших 10 см). Это критично для дистрибуции по клиентам. Например: «Pink Floyd 50cm» → name=«ROSA 50», variety=«PINK FLOYD», length_cm=50. === HS-КОДЫ ЦВЕТОВ (для справки) === 0603110000 — ROSA (розы) 0603120000 — DIANTHUS (гвоздики / Carnation) 0603140000 — CHRYSANTHEMUM (хризантемы) 0603191000 — ПРОЧИЕ (Gypsophila, Alstroemeria, Lisianthus, Hypericum, и т.д.) 06031300 — ORCHID (орхидеи) === СООТВЕТСТВИЕ ЗАГОЛОВКОВ К ПОЛЯМ JSON === - «МАРКА», «MARK», «MARKING», «CLIENT MARK», «CONSIGNEE MARK», «PO», «CUST.», «CUSTOMER», «REFERENCE», «REF», «CUST ORD» → marking. - «ПЛАНТАЦИЯ», «PLANTATION», «FARM», «GROWER», «SHIPPER», «EXPORTER», «FROM» → supplier (и одновременно plantation в позиции). - «СОРТ», «VARIETY», «TYPE», «FLOWER», «DESCRIPTION», «Omschrijving» → variety/name. - «РОСТ», «LENGTH», «CM», «SIZE», «Grade» → length_cm (только число, без «cm»). - «КОЛ-ВО КОРОБОК», «BOXES», «BOX», «QB», «QTY BOX», «Kolli» → boxes. - «СТЕБЛИ», «STEMS», «STEM», «QTY», «QUANTITY», «Aantal», «T. Units» → stems. - «НОМЕР AWB», «AWB», «AIRWAYBILL», «MAWB», «MAWB No.» → awb. - «ДАТА», «DATE», «FLIGHT DATE», «ETA» → invoice_date. Важные правила: - Если данные отсутствуют — ставь null (а не пустую строку и не "N/A"). - Числа — без кавычек. Дроби с запятой воспринимай как десятичные (0,43 = 0.43). - Не придумывай значения. Если маркировки нет — marking = null. - Если в документе есть колонка «ПЛАНТАЦИЯ», значение оттуда идёт в supplier. - Страну определяй по контексту (Ecuador=EC, Netherlands=NL, Kenya=KE, Colombia=CO, Israel=IL). - Названия цветов оставляй как в оригинале (обычно на английском). - Если несколько строк в таблице — сохрани их как отдельные items (не суммируй). - Если для строки boxes или stems пусто — ставь 0. - AWB формат: «045-1257 6826» или «14511394051» или «369-1069 4530» — бери как есть. - Для роз: ВСЕГДА указывай length_cm и группируй в name как «ROSA NN» (напр. ROSA 50). - flower_type: Rose, Carnation, Chrysanthemum, Gypsophila, Alstroemeria, Hypericum, Lisianthus, Stock, Trachelium, Eryngium, Statice, Solidago, Bouvardia. - Если цветок = роза (Rosa, Rose, Rosen) → flower_type = «Rose». """ PACKING_SYSTEM_PROMPT = """\ Ты — эксперт по парсингу упаковочных листов (packing list) для импорта цветов. Тебе дан текст из Excel/документа. Извлеки структурированные данные. Верни ТОЛЬКО валидный JSON (без markdown, без ```), строго по схеме: { "supplier": "поставщик / плантация", "client": "НЕ ЗАПОЛНЯЙ — всегда null", "marking": "КОРОТКИЙ КОД клиента-получателя. НЕ название компании!", "country": "страна (2-буквенный код ISO: EC, NL, KE, CO)", "awb": "номер авианакладной", "items": [ { "name": "название цветка (как в документе)", "flower_type": "тип цветка (Rose, Carnation, Chrysanthemum, Stock, Trachelium, и т.д.)", "variety": "сорт", "stems": количество_стеблей_число, "boxes": количество_физ_мест_число, "length_cm": длина_стебля_или_null, "marking": "маркировка позиции (короткий код)", "plantation": "плантация позиции (если есть)" } ], "total_stems": общее_количество_стеблей, "total_boxes": общее_количество_FULL_коробок_дробное } === ТИПЫ КОРОБОК И КОНВЕРТАЦИЯ (КРИТИЧНО!) === FB (Full Box) = 1.0 | HB (Half Box) = 0.5 | QB (Quarter Box) = 0.25 total_boxes ВСЕГДА в Full Boxes (дробное число). Примеры: 1 HB = 0.5 | 3 HB = 1.5 | 1 QB = 0.25 | 5 Half = 2.5. Нотации из русских пакинг-листов: «1hb/1.00full» → boxes=1, это 1.00 full box. «1hb/0.50full» → boxes=1, это 0.50 full box. «1QB/0.25FULL» → boxes=1, это 0.25 full box. «1-2/0.50fb» → boxes=1, это 0.50 full box. Если указано «Всего FB» или «Full Box:» в шапке — это total_boxes. === ПРАВИЛА МАРКИРОВКИ === МАРКИРОВКА — короткий код клиента (2–15 символов). Примеры: B-AQUA, B-AIGUL, B-FIT, K-BO, ARSK, NERO, NEFT, RASUL, MJ, MOSCOWFLOR, TSVETY, OM, KIRO, FPARK, ELA, SAMARA, OKTY, RONC, SWAIZH, KAZAN, MAGNER, ALMAZ, DLG, STAN, BOSS, ISHAIN, ROM SK, SOFSAR. Где искать маркировку в пакинг-листе (ПРИОРИТЕТ): 1) Поле «Маркировка:», «Mark:», «MARK:» в шапке. 2) Поле «Договор клиент: ECU - XXXXX» → XXXXX = маркировка. 3) Поле «Customer:», «Клиент:» (если короткий код: AIDA, ROM SK, ISHAIN). 4) Заголовки столбцов в таблице (каждый столбец = маркировка). 5) Имя файла может содержать маркировку (но приоритет — шапке документа). ОСОБЫЕ СЛУЧАИ: • «LUXUS STAN» → STAN (агент LUXUS, маркировка STAN). • «Клиент: RUSTAM (MAGNER)» → MAGNER. • «Договор клиент: ECU - ALMAZ» → ALMAZ. НЕ маркировка (верни null): ✗ Грузополучатели: «EFLOWERS B.V.», «Biflorica Marketplace Corp». ✗ Фермы/плантации (S.A., S.A.S., CIA. LTDA., LTD, INC). ✗ Транспортные агенты: «BEST FLORA TRUCK», «PACIFIC CARGO», «EBF CARGO». ✗ Адреса, города, страны. === РАСПОЗНАВАНИЕ ФОРМАТОВ ПАКИНГ-ЛИСТОВ === Формат A: Русский пакинг (XLSX, Эквадор) Шапка: Период, Клиент, Договор клиент. Строки: Место | Плантация | Юр. лицо | Тип | Сорт | Длина | Пачки | ШТ (или Всего ШТ). Место: «1hb/1.00full», «1QB/0.25FULL», «1-2/0.50fb». Тип: «РОЗА/РОЗА» = Rose, «CLAVEL/ГВОЗДИКА» = Carnation, «STOCK/МАТИОЛА» = Stock, «РОЗА Садовая» = Garden Rose, «OTROS» = Other. stems = Пачки × ШТ в пачке (или столбец «Всего ШТ»). Строка «Итого» = сумма пачек и стеблей. Формат B: ROM SK / подобные (XLSX, Эквадор) Шапка: Customer, Mark, AWB, Full Box, No. of pieces. Строки: Category | BOX # | TYPE/BOX (H=Half,Q=Quarter,F=Full) | FARM | VARIETY | LENGTH | STEMS/B | PACKS | STEMS. Формат C: Кенийский пакинг (XLSX) Шапка: CUSTOMER, TRUCK, AWB, Total Boxes, Total Stems. Строки: VARIETY | LENGTH | BOX NO. | STEMS PER BOX | TOTAL STEMS | ACTUAL WEIGHT | VOLUMETRIC WEIGHT | FARM NAME. Формат D: DLG / подобные (XLS, Эквадор, русский) Шапка: Дата UIO, Дата AMS, AWB, Клиент, Маркировка, Всего FB. Строки: МЕСТО | ПЛАНТАЦИЯ | СОРТ | ДЛИНА | ПАЧКИ | ШТ | ВСЕГО. МЕСТО: «1HB», «2hb» = Half Box. Всего FB в шапке = total_boxes. === СООТВЕТСТВИЕ ЗАГОЛОВКОВ === - «МЕСТО», «BOX #», «BOX NO.» → boxes (+ тип коробки). - «ПЛАНТАЦИЯ», «FARM», «FARM NAME», «GROWER» → plantation/supplier. - «СОРТ», «VARIETY», «DESCRIPTION» → variety/name. - «ДЛИНА», «LENGTH», «CM» → length_cm (только число). - «ПАЧКИ», «PACKS», «BUNCHES» → пачки (умножай на ШТ для stems). - «ШТ», «ШТ в пачке», «STEMS/B», «STEMS PER BOX» → stems на позицию. - «ВСЕГО», «Всего ШТ», «TOTAL STEMS», «STEMS» → stems. - «ТИП» / «TYPE» → flower_type. - «AWB», «MAWB» → awb. === ГРУППИРОВКА РОЗ ПО ДЛИНЕ (ВАЖНО) === Для роз name ДОЛЖНО содержать длину стебля: «ROSA 50», «ROSA 60», «ROSA 70» и т.д. Округляй до ближайших 10 см. Это критично для дистрибуции. === HS-КОДЫ ЦВЕТОВ === 0603110000 — ROSA | 0603120000 — DIANTHUS | 0603140000 — CHRYSANTHEMUM | 0603191000 — ПРОЧИЕ Важные правила: - Если данные отсутствуют — null. - Числа без кавычек. - Не суммируй строки — каждая строка = отдельный item. - total_boxes всегда в Full Boxes (HB=0.5, QB=0.25, FB=1.0). - Для роз: name=«ROSA NN», flower_type=«Rose», length_cm=NN. - flower_type: Rose, Carnation, Chrysanthemum, Gypsophila, Alstroemeria, Hypericum, Lisianthus, Stock, Trachelium, Eryngium, Statice, Solidago, Bouvardia. - Если в пакинг-листе маркировка вынесена в ОТДЕЛЬНЫЕ СТОЛБЦЫ: каждый столбец = маркировка; в нём стоит кол-во коробок/стеблей. Заголовок столбца (напр. «B-AQUA», «ARSK») — это маркировка. """ CERTIFICATE_SYSTEM_PROMPT = """\ Ты — эксперт по экспортным сертификатам (фитосанитарные PHYTO, Certificate of Origin FORM_A, EUR.1, DIAN) для импорта срезанных цветов из Эквадора, Колумбии и Кении. Тебе дан текст, извлечённый из PDF-сертификата. Извлеки структурированные данные. Верни ТОЛЬКО валидный JSON (без markdown, без ```), строго по схеме: { "country": "страна происхождения цветов: Ecuador | Colombia | Kenya | Chile | Israel", "cert_type": "тип документа: PHYTO | FORM_A | EUR1 | DIAN | null (если неясно)", "cert_numbers": ["все номера сертификатов в документе: EUR1-..., DIAN-..., номера при подписи Certificate No, Reference No"], "awb": "номер авианакладной (формат 123-45678901 или похожий) или null", "exporter": "компания-экспортёр или null", "date": "дата документа как в тексте или null", "items": [ { "flower": "ТИП цветка ЛАТИНСКИМ РОДОМ (см. словарь ниже): ROSA, DIANTHUS, ...", "farm": "ферма/плантация или null", "marking": "маркировка клиента (короткий код) или null", "invoice_nr": "номер инвойса, к которому относится позиция (строкой) или null", "cert_number": "номер сертификата (EUR1-.../DIAN-.../Reference No...), к которому относится позиция, или null", "boxes": количество_коробок_число_или_null, "weight_kg": вес_кг_число_или_null, "stems": количество_стеблей_целое_число } ], "total_stems": сумма_стеблей_по_всем_позициям, "total_boxes": сумма_коробок_или_null } === СЛОВАРЬ ЦВЕТОВ (обязательная нормализация поля flower) === ROSES / ROSAS / ROSE / РОЗА → ROSA CARNATIONS / CARNATION / CLAVEL / CLAVELES / MINI CARNATION / ГВОЗДИКА → DIANTHUS STOCK / MATHIOLA / ALHELI / ALELI → MATTHIOLA GYPSO / GYPSOPHILA / ГИПСОФИЛА → GYPSOPHILA HORTENSIA(S) / HYDRANGEA → HYDRANGEA STATICE / LIMONIUM → LIMONIUM CRISANTEMO / CHRYSANTHEMUM / POMPON → CHRYSANTHEMUM LILY / LILIES / LIRIOS → LILIUM EUSTOMA → LISIANTHUS | CARTUCHOS / ZANTEDESCHIA → CALLAS PEONIES / PAEONIA → PEONY | GIRASOL → SUNFLOWER Остальные (ALSTROEMERIA, HYPERICUM, SOLIDAGO, GERBERA …) — как есть, ВЕРХНИМ регистром. === НОМЕРА СЕРТИФИКАТОВ И ИНВОЙСОВ (КРИТИЧНО!) === СЕРТИФИКАТЫ (место в cert_numbers / cert_number): - EUR.1 / EUR1: серийный номер бланка, часто с буквой — «EUR1-B1069139», «EUR1-0320033». В тексте может выглядеть как «EUR.1 No A 0320033» → верни «EUR1-A0320033» (без пробелов). - Колумбия: номер DIAN (длинный цифровой) → «DIAN-260260000041304». - Число при подписи «Certificate No / Certificate number / Cert. No» — это сертификат, ДАЖЕ если оно голое, без префикса (напр. «Certificate No 526674» → «526674»). - Фитосанитарный сертификат Кении (PHYTO): номер бланка часто стоит ГОЛЫМ числом на отдельной строке без подписи (подпись осталась на скане) — это СЕРТИФИКАТ, если оно НЕ совпадает с номером при «INVOICE NO» (напр. отдельная строка «526674» + «INVOICE NO 5774» → cert_numbers=[«526674»], invoice_nr=«5774»). - FORM_A (Certificate of Origin, Kenya ULTRA FLO): номер стоит после «Reference No.» на странице с заголовком «GENERALISED SYSTEM OF PREFERENCES» или «CERTIFICATE OF ORIGIN». Напр. «Reference No. 533392» → cert_numbers=[«533392»], cert_type=«FORM_A». Это СЕРТИФИКАТ, не путай с инвойсом! ИНВОЙСЫ (это НЕ сертификаты, их место ТОЛЬКО в invoice_nr): - колумбийская проформа «IFC 016031» → «IFC-016031» (это номер инвойса!); - кенийские «EFE 841» → «EFE-841» (номер инвойса); - номера MASTER INVOICE и голые числа при подписи Invoice / Factura. - Формат в ответе: ПРЕФИКС-НОМЕР без пробелов (EUR1-XXX, DIAN-XXX, IFC-XXX, EFE-XXX). - Если номеров сертификатов в тексте нет (они на печати/скане) — верни cert_numbers: []. - Для Ecuador EUR.1 номер — это 7-значное число, начинающееся с «03» (например: 0323322, 0323241), ИЛИ длинный номер с буквенным префиксом (например: NA09908859202600041645P). НЕ придумывай номера! Номера MASTER INVOICE — это НЕ сертификаты, это инвойсы! === ФОРМАТЫ ДОКУМЕНТОВ === Формат Ecuador: в одном PDF НЕСКОЛЬКО блоков, у каждого СВОЙ мастер-инвойс. Обработай ВСЕ блоки, не только первый! Два вида блоков: а) «MASTER INVOICE # NNNNNNNN» — таблица Concept|Farm|Client|Invoice|F.BXS|Pieces|Tariff#|Stems|… Здесь колонка «Invoice» — внутренний номер фермы, его НЕ бери! invoice_nr каждой позиции = число из заголовка «MASTER INVOICE #» её блока. б) «MASTER REPORT» с полем «InvoiceNo.» (напр. «EC 10026525») — таблица BXS|PCS|WEIGHT|HTSUSA|FLOWER|STEMS|…; invoice_nr = InvoiceNo. этого блока БЕЗ кода страны («EC 10026525» → invoice_nr=«10026525»). Каждая таблица может встречаться в тексте ДВАЖДЫ (обычная и через «|») — каждую строку учитывай ОДИН раз. cert_number позиции = EUR.1 её блока (если есть). Формат Colombia: сертификат DIAN (на скане) + проформа IFC (в текстовом слое). Шапка: «NIT 901.743.377-8 PROFORMA IFC-016031» → invoice_nr = «IFC-016031». Дата: «R.E. 20/06/2026» → date = «20/06/2026». Таблица: PIECES FULLES DESCRIPTION ATPA CO UNITS PRICE TOTAL FOB Пример: «26 6,5 CARNATIONS T C.O 10400 0,10 1040,00» boxes=26, flower=DIANTHUS (CARNATIONS→DIANTHUS), stems=10400 (колонка UNITS). ВАЖНО: европейский формат десятичных — запятая вместо точки: «6,5»=6.5, «0,10»=0.10. Строка итогов может быть искажена: «68,00 17F,0U0LLES TOTAL FULL 17,00 13070 1307,00» — 13070 = total_stems, 68 = total_boxes. Игнорируй итоговые строки — парси только позиции! cert_number = DIAN-номер (на скане, если OCR найдёт), иначе null. Формат Kenya: PDF может быть полностью сканированным (без текстового слоя). Если есть текст: строки вида «ROSES 6950 STEMS» → flower=ROSA, stems=6950. invoice_nr — число при «INVOICE NO» (напр. «INVOICE NO 5774» → «5774»). cert_number — голый номер бланка фито (напр. отдельная строка «526674») или EUR.1 с буквой. НЕ путай номер сертификата (бланк фито) с номером инвойса! Формат Kenya/Milele (ВАЖНО — таблица в Master Invoice): После страницы сертификата идёт страница Master Invoice с таблицей VARIETY: «VARIETY # BOXES P. RATE TT STEMS TT VALUE» «Roses 35-80 2 195 390 $0,10 $39,00» Здесь: flower=ROSA, grade=«35-80» (ростовка/grade), boxes=2, stems=390. ОБЯЗАТЕЛЬНО бери данные из таблицы Master Invoice — она точнее строки сертификата! Ростовку (grade/cm range) пиши в поле farm: «35-80 cm» или как в тексте. Если в таблице несколько строк VARIETY — каждая строка = отдельный item. cert_number каждой позиции = голый номер в начале страницы сертификата (526829, 526827 и т.д.). Формат Kenya/ULTRA FLO (FORM_A — Certificate of Origin): Страница с заголовком «GENERALISED SYSTEM OF PREFERENCES» или «CERTIFICATE OF ORIGIN». Номер после «Reference No.» — это cert_number (напр. «533392»), cert_type=«FORM_A». На странице есть строка вида «3 BOXES ROSES 2400 STEMS» → boxes=3, flower=ROSA, stems=2400. invoice_nr — число при «INVOICE NO» или «EFE» на этой или соседней странице. ОБРАБАТЫВАЙ FORM_A так же, как PHYTO — это полноценный сертификат! === ВАЖНЫЕ ПРАВИЛА === - Обрабатывай ВСЕ типы документов в блоке: PHYTO, FORM_A, EUR1, DIAN. - stems — целое число; НЕ путай стебли с коробками и весом. - Каждая строка таблицы = отдельный item; НЕ суммируй строки. - НЕ выдумывай номера сертификатов и инвойсов — только то, что есть в тексте. - Если у позиции нет своего cert_number — поставь общий номер документа или null. - Если данных нет — null. Числа без кавычек. """ def _get_corrections_context() -> str: """Build dynamic corrections section for AI prompts from learned user fixes.""" try: import sys from pathlib import Path _proj = str(Path(__file__).resolve().parent.parent) if _proj not in sys.path: sys.path.insert(0, _proj) from corrections import load_corrections corr = load_corrections() except Exception: return '' lines = [] # Flower synonyms if corr.flower_synonyms: for abbr, full in list(corr.flower_synonyms.items())[:20]: lines.append(f'- "{abbr}" = {full}') # Supplier rules if corr.supplier_rules: for sup, rules in list(corr.supplier_rules.items())[:10]: parts = [] if rules.get('country'): parts.append(f'country={rules["country"]}') if rules.get('default_flower'): parts.append(f'flower={rules["default_flower"]}') if rules.get('hs_code'): parts.append(f'HS={rules["hs_code"]}') if parts: lines.append(f'- Supplier "{sup}": {", ".join(parts)}') # Recent corrections from history (top by applied_count) ai_items = corr.get_corrections_for_ai(max_items=15) for item in ai_items: orig = item.get('original_value', '') fixed = item.get('corrected_value', '') field = item.get('field', '') src = item.get('source_text', '') if orig and fixed: if src: # Include source context so AI understands the pattern src_short = src[:120].replace('\n', ' ') lines.append( f'- Source: "{src_short}"\n' f' [{field}] AI said "{orig}" -> User corrected to "{fixed}"' ) else: lines.append(f'- [{field}] "{orig}" \u2192 "{fixed}"') if not lines: return '' header = ( '\n\n=== USER CORRECTIONS (learned from manual fixes, apply these!) ===\n' ) # Limit to ~500 tokens worth of text result = header + '\n'.join(lines) if len(result) > 2000: result = result[:2000] + '\n... [truncated]' return result def parse_invoice_with_ai(text: str) -> dict: """ Отправляет извлечённый текст инвойса в DeepSeek для структурирования. Args: text: Сырой текст из PDF (извлечённый pdfplumber) Returns: dict с структурированными данными инвойса """ if not text or not text.strip(): return {"error": "Пустой текст"} # Обрезаем слишком длинный текст (лимит ~30K символов; DeepSeek chat поддерживает 64K контекст) if len(text) > 30000: text = text[:30000] + "\n... [текст обрезан]" content = "" try: # Build system prompt with dynamic corrections system_prompt = INVOICE_SYSTEM_PROMPT + _get_corrections_context() response = client.chat.completions.create( model=DEEPSEEK_MODEL, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": f"Вот текст инвойса:\n\n{text}"}, ], temperature=0.1, # Минимальная креативность для точности max_tokens=8192, response_format={"type": "json_object"}, ) content = response.choices[0].message.content or "" content = _clean_ai_json_response(content) result = _loads_ai_json(content) result["_ai_model"] = DEEPSEEK_MODEL result["_tokens_used"] = response.usage.total_tokens if response.usage else None return result except json.JSONDecodeError as e: logger.error(f"AI вернул невалидный JSON: {e}") return {"error": f"Невалидный JSON от AI: {e}", "_raw_response": content} except Exception as e: logger.error(f"Ошибка AI-парсинга: {e}") return {"error": f"Ошибка API: {e}"} def parse_packing_with_ai(text: str) -> dict: """ Отправляет текст упаковочного листа в DeepSeek для структурирования. """ if not text or not text.strip(): return {"error": "Пустой текст"} if len(text) > 30000: text = text[:30000] + "\n... [текст обрезан]" content = "" try: # Build system prompt with dynamic corrections system_prompt = PACKING_SYSTEM_PROMPT + _get_corrections_context() response = client.chat.completions.create( model=DEEPSEEK_MODEL, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": f"Вот текст упаковочного листа:\n\n{text}"}, ], temperature=0.1, max_tokens=8192, response_format={"type": "json_object"}, ) content = response.choices[0].message.content or "" content = _clean_ai_json_response(content) result = _loads_ai_json(content) result["_ai_model"] = DEEPSEEK_MODEL result["_tokens_used"] = response.usage.total_tokens if response.usage else None return result except json.JSONDecodeError as e: logger.error(f"AI вернул невалидный JSON: {e}") return {"error": f"Невалидный JSON от AI: {e}", "_raw_response": content} except Exception as e: logger.error(f"Ошибка AI-парсинга: {e}") return {"error": f"Ошибка API: {e}"} # =================== Kimi K3 =================== def parse_invoice_with_kimi(text: str, _retry: int = 0) -> dict: """Парсинг инвойса через Kimi K3.""" if not text or not text.strip(): return {"error": "Пустой текст"} if len(text) > 60000: text = text[:60000] + "\n... [текст обрезан]" content = "" try: # Build system prompt with dynamic corrections system_prompt = INVOICE_SYSTEM_PROMPT + _get_corrections_context() response = kimi_client.chat.completions.create( model=KIMI_MODEL, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": f"Вот текст инвойса:\n\n{text}"}, ], temperature=1, max_tokens=8192, response_format={"type": "json_object"}, ) content = response.choices[0].message.content or "" content = _clean_ai_json_response(content) if not content: if _retry < 1: logger.warning("Kimi вернул пустой ответ, повторяем...") return parse_invoice_with_kimi(text, _retry=_retry + 1) return {"error": "Kimi вернул пустой ответ (повторная попытка не помогла)"} result = _loads_ai_json(content) result["_ai_model"] = "kimi-k3" result["_tokens_used"] = response.usage.total_tokens if response.usage else None return result except json.JSONDecodeError as e: logger.error(f"Kimi вернул невалидный JSON: {e}") if _retry < 1: logger.warning("Повторяем запрос к Kimi...") return parse_invoice_with_kimi(text, _retry=_retry + 1) return {"error": f"Невалидный JSON от Kimi: {e}", "_raw_response": content} except Exception as e: logger.error(f"Ошибка Kimi-парсинга: {e}") return {"error": f"Ошибка Kimi API: {e}"} def parse_packing_with_kimi(text: str, _retry: int = 0) -> dict: """Парсинг packing-листа через Kimi K3.""" if not text or not text.strip(): return {"error": "Пустой текст"} if len(text) > 60000: text = text[:60000] + "\n... [текст обрезан]" content = "" try: # Build system prompt with dynamic corrections system_prompt = PACKING_SYSTEM_PROMPT + _get_corrections_context() response = kimi_client.chat.completions.create( model=KIMI_MODEL, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": f"Вот текст упаковочного листа:\n\n{text}"}, ], temperature=1, max_tokens=8192, response_format={"type": "json_object"}, ) content = response.choices[0].message.content or "" content = _clean_ai_json_response(content) if not content: if _retry < 1: logger.warning("Kimi вернул пустой ответ (packing), повторяем...") return parse_packing_with_kimi(text, _retry=_retry + 1) return {"error": "Kimi вернул пустой ответ (повторная попытка не помогла)"} result = _loads_ai_json(content) result["_ai_model"] = "kimi-k3" result["_tokens_used"] = response.usage.total_tokens if response.usage else None return result except json.JSONDecodeError as e: logger.error(f"Kimi вернул невалидный JSON: {e}") if _retry < 1: logger.warning("Повторяем запрос к Kimi (packing)...") return parse_packing_with_kimi(text, _retry=_retry + 1) return {"error": f"Невалидный JSON от Kimi: {e}", "_raw_response": content} except Exception as e: logger.error(f"Ошибка Kimi-парсинга: {e}") return {"error": f"Ошибка Kimi API: {e}"} def parse_certificate_with_ai(text: str) -> dict: """Парсинг сертификата происхождения через DeepSeek.""" if not text or not text.strip(): return {"error": "Пустой текст"} if len(text) > 30000: text = text[:30000] + "\n... [текст обрезан]" content = "" try: system_prompt = CERTIFICATE_SYSTEM_PROMPT + _get_corrections_context() response = client.chat.completions.create( model=DEEPSEEK_MODEL, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": f"Вот текст сертификата происхождения:\n\n{text}"}, ], temperature=0.1, max_tokens=8192, response_format={"type": "json_object"}, ) content = response.choices[0].message.content or "" content = _clean_ai_json_response(content) result = _loads_ai_json(content) result["_ai_model"] = DEEPSEEK_MODEL result["_tokens_used"] = response.usage.total_tokens if response.usage else None return result except json.JSONDecodeError as e: logger.error(f"AI вернул невалидный JSON (certificate): {e}") return {"error": f"Невалидный JSON от AI: {e}", "_raw_response": content} except Exception as e: logger.error(f"Ошибка AI-парсинга сертификата: {e}") return {"error": f"Ошибка API: {e}"} def parse_certificate_with_kimi(text: str, _retry: int = 0) -> dict: """Парсинг сертификата происхождения через Kimi K3.""" if not text or not text.strip(): return {"error": "Пустой текст"} if len(text) > 60000: text = text[:60000] + "\n... [текст обрезан]" content = "" try: system_prompt = CERTIFICATE_SYSTEM_PROMPT + _get_corrections_context() response = kimi_client.chat.completions.create( model=KIMI_MODEL, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": f"Вот текст сертификата происхождения:\n\n{text}"}, ], temperature=1, max_tokens=8192, response_format={"type": "json_object"}, ) content = response.choices[0].message.content or "" content = _clean_ai_json_response(content) if not content: if _retry < 1: logger.warning("Kimi вернул пустой ответ (certificate), повторяем...") return parse_certificate_with_kimi(text, _retry=_retry + 1) return {"error": "Kimi вернул пустой ответ (повторная попытка не помогла)"} result = _loads_ai_json(content) result["_ai_model"] = "kimi-k3" result["_tokens_used"] = response.usage.total_tokens if response.usage else None return result except json.JSONDecodeError as e: logger.error(f"Kimi вернул невалидный JSON (certificate): {e}") if _retry < 1: logger.warning("Повторяем запрос к Kimi (certificate)...") return parse_certificate_with_kimi(text, _retry=_retry + 1) return {"error": f"Невалидный JSON от Kimi: {e}", "_raw_response": content} except Exception as e: logger.error(f"Ошибка Kimi-парсинга сертификата: {e}") return {"error": f"Ошибка Kimi API: {e}"} def extract_certificate_numbers_with_ocr( pdf_path: str, source_text: str | None = None, orig_name: str | None = None, ) -> list[str]: """OCR-извлечение номеров EUR1/DIAN со сканов сертификатов. Возвращает список строк вида ['EUR1-0320033', 'DIAN-260260000026123']. Используется для дополнения текстового слоя AI-парсеру. Для Эквадора тяжёлый Paddle OCR пропускаем: у эквадорских PDF есть нормальный текстовый слой, а номера сертификатов AI видит из текста. """ # Быстрая проверка по имени/тексту до запуска тяжёлого OCR. name = os.path.basename(pdf_path) hay = ' '.join([name, orig_name or '', source_text or '']).lower() if 'ecuador' in hay or re.search(r'\bec\b', hay) is not None: return [] try: import sys from pathlib import Path _proj = str(Path(__file__).resolve().parent.parent) if _proj not in sys.path: sys.path.insert(0, _proj) from ocr_eur1 import ocr_eur1_numbers, ocr_dian_numbers, is_ocr_available if not is_ocr_available(): return [] out: list[str] = [] eur1_res = ocr_eur1_numbers(pdf_path) for nr in eur1_res.numbers: prefixed = f'EUR1-{nr}' if prefixed not in out: out.append(prefixed) dian_res = ocr_dian_numbers(pdf_path) for nr in dian_res.numbers: prefixed = f'DIAN-{nr}' if prefixed not in out: out.append(prefixed) return out except Exception: return [] def extract_text_from_pdf(pdf_path: str) -> str: """Извлекает весь текст из PDF с помощью pdfplumber.""" import pdfplumber pages_text = [] with pdfplumber.open(pdf_path) as pdf: for i, page in enumerate(pdf.pages): text = page.extract_text() or "" # Также пробуем извлечь таблицы tables = page.extract_tables() or [] table_text = "" for table in tables: for row in table: if row: table_text += " | ".join(str(cell or "") for cell in row) + "\n" combined = text if table_text: combined += "\n[ТАБЛИЦА]\n" + table_text pages_text.append(f"--- Страница {i+1} ---\n{combined}") return "\n\n".join(pages_text) def extract_text_from_excel(excel_path: str) -> str: """Извлекает текст из Excel файла (.xlsx/.xlsm через openpyxl, .xls через xlrd).""" import os ext = os.path.splitext(excel_path)[1].lower() if ext == '.xls': # Старый BIFF-формат — openpyxl не поддерживает, используем xlrd try: import xlrd except ImportError: raise RuntimeError( 'xlrd не установлен — невозможно прочитать .xls. ' 'Установите: pip install xlrd' ) wb = xlrd.open_workbook(excel_path) all_text = [] for sheet in wb.sheets(): all_text.append(f"--- Лист: {sheet.name} ---") for row_idx in range(sheet.nrows): cells = [str(sheet.cell_value(row_idx, c) or "") for c in range(sheet.ncols)] # Strip trailing empty cells to reduce token noise while cells and not cells[-1].strip(): cells.pop() if any(c.strip() for c in cells): all_text.append(" | ".join(cells)) text = "\n".join(all_text) # Strip URLs (picture links waste tokens, not useful for parsing) text = re.sub(r'https?://\S+', '', text) text = re.sub(r' +', ' ', text) return text # .xlsx / .xlsm — openpyxl import openpyxl wb = openpyxl.load_workbook(excel_path, read_only=True, data_only=True) all_text = [] for sheet_name in wb.sheetnames: ws = wb[sheet_name] # Chartsheet и прочие не-табличные листы данных не содержат — пропускаем if not hasattr(ws, 'iter_rows'): continue all_text.append(f"--- Лист: {sheet_name} ---") for row in ws.iter_rows(values_only=True): cells = [str(c) if c is not None else "" for c in row] # Strip trailing empty cells to reduce token noise while cells and not cells[-1].strip(): cells.pop() if any(c.strip() for c in cells): all_text.append(" | ".join(cells)) wb.close() text = "\n".join(all_text) # Strip URLs (picture links waste tokens, not useful for parsing) text = re.sub(r'https?://\S+', '', text) text = re.sub(r' +', ' ', text) return text