/
alsoalgo
/
CTF
Обзор
Документация
Войти
/
alsoalgo
/
CTF
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
src/components/data_extractor.py
1 189 строк
77 KB
alsoalgo
some improvements
16 дек 2025, 13:03
16 дек 2025, 13:03
72b7a7e
Код
Авторство
О чём код?
import pandas as pd import numpy as np from typing import Tuple, Dict, Any, Union, Optional, List import re import logging import json from datetime import datetime logger = logging.getLogger(__name__) class DataExtractor: """ Class for extracting, transforming, and creating metadata for documents. Преобразует данные из различных документов в единый шаблон заявки CTF. """ def __init__(self, use_llm_aggregation: bool = False, ocr_handler=None): """ Инициализация DataExtractor. :param use_llm_aggregation: Использовать ли LLM для агрегации данных из всех источников :param ocr_handler: OCRHandler для использования LLM (если use_llm_aggregation=True) """ self.use_llm_aggregation = use_llm_aggregation self.ocr_handler = ocr_handler # Маппинг полей из разных типов документов в шаблон заявки self.field_mapping = { 'invoice': { 'supplier': ['Supplier', 'supplier', 'Vendor', 'vendor', 'Seller', 'seller'], 'off_taker': ['Off-taker', 'off-taker', 'Buyer', 'buyer', 'Customer', 'customer', 'Purchaser', 'purchaser'], 'product': ['Product', 'product', 'Product Description', 'product description', 'Item', 'item', 'Goods', 'goods', 'Commodity', 'commodity'], 'quantity': ['Quantity', 'quantity', 'Qty', 'qty', 'QTY', 'Amount', 'amount'], 'unit': ['Unit', 'unit', 'Units', 'units', 'UOM', 'uom'], 'unit_price': ['Unit Price', 'unit price', 'Price', 'price', 'Price per Unit', 'price per unit', 'Price, USD', 'price, usd'], 'amount': ['Total Amount', 'total amount', 'Amount', 'amount', 'Total Value', 'total value', 'Total', 'total', 'Sum', 'sum', 'Amount, USD', 'amount, usd'], 'currency': ['Currency', 'currency', 'Curr', 'curr'], 'date': ['Date', 'date', 'Invoice Date', 'invoice date', 'Issue Date', 'issue date'], 'invoice_number': ['Invoice Number', 'invoice number', 'Invoice No', 'invoice no', 'Invoice #', 'invoice #', 'INV', 'inv'], 'status': ['Status', 'status', 'Payment Status', 'payment status'], 'payment_terms': ['Payment Terms', 'payment terms', 'Terms', 'terms', 'Payment Conditions', 'payment conditions', 'Payment terms', 'payment terms'], 'payment_instrument': ['Payment Instrument', 'payment instrument', 'Payment Method', 'payment method', 'Payment Type', 'payment type', 'Payment', 'payment', 'Payment instrument', 'payment instrument'] }, 'contract': { 'supplier': ['Supplier', 'supplier', 'Vendor', 'vendor', 'Seller', 'seller'], 'off_taker': ['Off-taker', 'off-taker', 'Buyer', 'buyer', 'Customer', 'customer', 'Purchaser', 'purchaser'], 'product': ['Product Description', 'product description', 'Product', 'product', 'Item', 'item', 'Goods', 'goods', 'Commodity', 'commodity'], 'quantity': ['Quantity', 'quantity', 'Qty', 'qty', 'QTY', 'Amount', 'amount', 'Quantity, MT', 'quantity, mt'], 'unit': ['Unit', 'unit', 'Units', 'units', 'UOM', 'uom'], 'unit_price': ['Unit Price', 'unit price', 'Price', 'price', 'Price per Unit', 'price per unit', 'Price, USD', 'price, usd'], 'amount': ['Total Value', 'total value', 'Amount', 'amount', 'Total', 'total', 'Sum', 'sum', 'Amount, USD', 'amount, usd'], 'currency': ['Currency', 'currency', 'Curr', 'curr'], 'incoterm': ['Incoterm', 'incoterm', 'INCOTERM', 'INCOTERMS', 'Terms of Delivery', 'terms of delivery', 'Delivery Terms', 'delivery terms'], 'payment_instrument': ['Payment Instrument', 'payment instrument', 'Payment Method', 'payment method', 'Payment Type', 'payment type', 'Payment', 'payment', 'Payment instrument', 'payment instrument'], 'contract_date': ['Contract Date', 'contract date', 'Date', 'date', 'Agreement Date', 'agreement date'], 'delivery_date': ['Delivery Date', 'delivery date', 'Shipment Date', 'shipment date', 'Delivery', 'delivery'], 'payment_terms': ['Payment Terms', 'payment terms', 'Terms', 'terms', 'Payment Conditions', 'payment conditions', 'Payment terms', 'payment terms'], 'contract_id': ['Contract ID', 'contract id', 'Contract Number', 'contract number', 'Contract #', 'contract #'], 'status': ['Status', 'status', 'Contract Status', 'contract status'], 'vessel_name': ['Vessel', 'vessel', 'Ship', 'ship', 'Vessel Name', 'vessel name', 'Forwarder', 'forwarder', 'Warehouse', 'warehouse', 'Name of vessel/forwarder/warehouse or any other'], 'initial_stage': ['Initial Stage', 'initial stage', 'Stage', 'stage', 'Transaction Stage', 'transaction stage', 'Initial stage of transaction'], 'underlying_document': ['Underlying Document', 'underlying document', 'Document', 'document', 'Supporting Document', 'supporting document', 'Underlying document'], 'additional_notes': ['Additional Notes', 'additional notes', 'Notes', 'notes', 'Remarks', 'remarks', 'Comments', 'comments'], 'country_of_origin': ['Country of Origin', 'country of origin', 'Origin', 'origin', 'Origin Country', 'origin country'], 'country_of_destination': ['Country of Destination', 'country of destination', 'Destination', 'destination', 'Destination Country', 'destination country'] }, 'insurance': { 'supplier': ['Insured', 'insured', 'Policyholder', 'policyholder', 'Beneficiary', 'beneficiary'], 'off_taker': ['Beneficiary', 'beneficiary', 'Insured', 'insured'], 'product': ['Product', 'product', 'Coverage', 'coverage', 'Insured Goods', 'insured goods'], 'amount': ['Sum Insured', 'sum insured', 'Coverage Amount', 'coverage amount', 'Amount', 'amount', 'Value', 'value'], 'currency': ['Currency', 'currency', 'Curr', 'curr'], 'insurance_policy_number': ['Policy Number', 'policy number', 'Policy No', 'policy no', 'Policy #', 'policy #', 'Certificate Number', 'certificate number'], 'insurance_date': ['Policy Date', 'policy date', 'Issue Date', 'issue date', 'Effective Date', 'effective date', 'Date', 'date'], 'insurance_company': ['Insurance Company', 'insurance company', 'Insurer', 'insurer', 'Company', 'company'] }, 'forwarder': { 'supplier': ['Shipper', 'shipper', 'Consignor', 'consignor'], 'off_taker': ['Consignee', 'consignee', 'Receiver', 'receiver', 'Recipient', 'recipient'], 'product': ['Cargo', 'cargo', 'Goods', 'goods', 'Commodity', 'commodity', 'Description', 'description'], 'quantity': ['Quantity', 'quantity', 'Qty', 'qty', 'Weight', 'weight', 'Volume', 'volume'], 'unit': ['Unit', 'unit', 'Units', 'units', 'UOM', 'uom'], 'amount': ['Freight', 'freight', 'Amount', 'amount', 'Total', 'total', 'Charges', 'charges'], 'currency': ['Currency', 'currency', 'Curr', 'curr'], 'forwarder_name': ['Forwarder', 'forwarder', 'Carrier', 'carrier', 'Shipping Company', 'shipping company'], 'forwarder_invoice_number': ['Invoice Number', 'invoice number', 'Invoice No', 'invoice no', 'BL Number', 'bl number', 'Bill of Lading', 'bill of lading'], 'forwarder_date': ['Date', 'date', 'Invoice Date', 'invoice date', 'Shipping Date', 'shipping date'] } } def _generate_mock_metadata(self, df: pd.DataFrame, file_name: str) -> pd.DataFrame: """ A helper function that simulates the neural network logic by creating MultiIndex metadata (cell-by-cell status). Status: 0: No source link (data comes from a template). Background: Yellow. 1: Recognition successful (confident). Background: White. 2: Recognition unsuccessful (low confidence, requires manual editing). Background: Lavender/Purple. """ df_transformed = df.astype(str).reset_index(drop=True) num_rows = df_transformed.shape[0] index_tuples = [] source_data = { 'source_file': [], 'source_row': [], 'source_col': [], 'status': [] } # Generate metadata for each cell for row_idx in range(num_rows): for col_name in df_transformed.columns: index_tuples.append((row_idx, col_name)) source_data['source_file'].append(file_name) source_data['source_row'].append(row_idx) source_data['source_col'].append(col_name) # Mockup: Random status (0, 1, or 2) source_data['status'].append(np.random.choice([0, 1, 2])) multi_index = pd.MultiIndex.from_tuples(index_tuples, names=['Row', 'Col']) metadata_df = pd.DataFrame({ 'source_file': source_data['source_file'], 'source_row': source_data['source_row'], 'source_col': source_data['source_col'], 'status': source_data['status'] }, index=multi_index) return metadata_df def _detect_document_type(self, filename: str, df: pd.DataFrame) -> str: """ Определяет тип документа по имени файла и структуре данных. :param filename: Имя файла :param df: DataFrame с данными :return: Тип документа ('invoice', 'contract', 'insurance', 'unknown') """ filename_lower = filename.lower() if 'invoice' in filename_lower or 'inv' in filename_lower: return 'invoice' elif 'contract' in filename_lower or 'ctf' in filename_lower: return 'contract' elif 'insurance' in filename_lower or 'policy' in filename_lower: return 'insurance' elif 'forwarder' in filename_lower: return 'forwarder' else: # Пытаемся определить по колонкам cols_lower = [str(col).lower() for col in df.columns] if any('invoice' in col for col in cols_lower): return 'invoice' elif any('contract' in col for col in cols_lower): return 'contract' return 'unknown' def _extract_field_value(self, df: pd.DataFrame, field_name: str, doc_type: str, row_idx: int = 0, sources_info: Optional[Dict] = None) -> Tuple[Optional[Any], Optional[int], Optional[str], int, Optional[Dict]]: """ Извлекает значение поля из DataFrame. :param df: DataFrame с данными :param field_name: Имя поля для извлечения :param doc_type: Тип документа :param row_idx: Индекс строки (по умолчанию 0) :param sources_info: Словарь с информацией об источниках (опционально) :return: (значение, индекс строки, имя колонки, статус уверенности, source_details) """ if doc_type not in self.field_mapping: return None, None, None, 2, None mapping = self.field_mapping[doc_type].get(field_name, []) if not mapping: return None, None, None, 0, None # Ищем колонку по маппингу for col in df.columns: col_str = str(col).strip() if col_str in mapping: try: value = df.iloc[row_idx][col] if pd.notna(value) and str(value).strip(): # Получаем source_details из sources_info, если доступны source_details = None if sources_info and 'cells' in sources_info: cell_key = f"{row_idx}_{col_str}" if cell_key in sources_info['cells']: source_details = sources_info['cells'][cell_key] # Статус 1 если значение найдено и не пустое return str(value).strip(), row_idx, col_str, 1, source_details except (IndexError, KeyError): pass # Если не нашли, пробуем найти в любой строке for col in df.columns: col_str = str(col).strip() if col_str in mapping: for idx in range(len(df)): try: value = df.iloc[idx][col] if pd.notna(value) and str(value).strip(): # Получаем source_details из sources_info, если доступны source_details = None if sources_info and 'cells' in sources_info: cell_key = f"{idx}_{col_str}" if cell_key in sources_info['cells']: source_details = sources_info['cells'][cell_key] return str(value).strip(), idx, col_str, 1, source_details except (IndexError, KeyError): continue return None, None, None, 2, None def _create_template_dataframe(self) -> pd.DataFrame: """ Создает шаблон DataFrame для заявки CTF с двумя колонками PURCHASE и SALE. Структура соответствует recap. Georgian Solutions.xlsx. :return: Пустой шаблон DataFrame """ # Определяем поля согласно структуре документа # Убрали FUND X, PURCHASE, SALE - это не строки данных, а заголовки колонок fields = [ 'Supplier', # Для PURCHASE 'Off-taker', # Для SALE 'Amount, USD', # Для обеих колонок 'Quantity, MT', # Для обеих колонок 'Price, USD', # Для обеих колонок 'Incoterm', # Для обеих колонок 'Goods', # Для обеих колонок 'Payment terms', # Для обеих колонок 'Payment instrument', # Для обеих колонок 'Country of Origin', # Для PURCHASE 'Country of Destination', # Для SALE 'Name of vessel/forwarder/warehouse or any other', # Общее 'Initial stage of transaction', # Общее 'Underlying document', # Общее 'Additional Notes' # Общее ] template_data = { 'Field': fields, 'PURCHASE': [''] * len(fields), 'SALE': [''] * len(fields), 'PURCHASE Confidence': [0] * len(fields), # Степень уверенности для PURCHASE (0-100) 'SALE Confidence': [0] * len(fields) # Степень уверенности для SALE (0-100) } return pd.DataFrame(template_data) def transform_dataframes(self, dataframes: Dict[str, Union[pd.DataFrame, Tuple[pd.DataFrame, Optional[Dict]]]]) -> Tuple[ Union[pd.DataFrame, None], Union[pd.DataFrame, None]]: """ Преобразует данные из нескольких документов в единый шаблон заявки. :param dataframes: Словарь где ключ - имя файла, значение - DataFrame или tuple (DataFrame, sources_info) :return: Кортеж (final_dataframe, metadata_df) или (None, None) """ if not isinstance(dataframes, dict) or not dataframes: return None, None # Создаем шаблон final_dataframe = self._create_template_dataframe() # Подготавливаем метаданные index_tuples = [] source_data = { 'source_file': [], 'source_row': [], 'source_col': [], 'status': [], 'source_page': [], 'source_context': [], 'extraction_method': [] } # Обрабатываем каждый документ for filename, df_or_tuple in dataframes.items(): # Обрабатываем как tuple для обратной совместимости if isinstance(df_or_tuple, tuple): df, sources_info = df_or_tuple else: df = df_or_tuple sources_info = None if df is None or df.empty: logger.warning(f"Документ '{filename}' пуст или не загружен, пропускаю") continue doc_type = self._detect_document_type(filename, df) logger.info(f"Обрабатываю документ '{filename}' (тип: {doc_type})") # Извлекаем данные из документа extracted_fields = {} # Основные поля (извлекаются для всех типов документов) for field in ['supplier', 'off_taker', 'product', 'quantity', 'unit', 'unit_price', 'amount', 'currency', 'status', 'incoterm', 'payment_instrument', 'vessel_name', 'initial_stage', 'underlying_document', 'additional_notes', 'country_of_origin', 'country_of_destination']: value, row_idx, col_name, status, source_details = self._extract_field_value(df, field, doc_type, sources_info=sources_info) if value: extracted_fields[field] = (value, row_idx, col_name, status, filename, source_details) # Специфичные поля для invoice if doc_type == 'invoice': for field in ['invoice_number', 'date']: value, row_idx, col_name, status, source_details = self._extract_field_value(df, field, doc_type, sources_info=sources_info) if value: if field == 'date': extracted_fields['contract_date'] = (value, row_idx, col_name, status, filename, source_details) else: extracted_fields[field] = (value, row_idx, col_name, status, filename, source_details) # Специфичные поля для contract if doc_type == 'contract': for field in ['contract_date', 'delivery_date', 'payment_terms', 'contract_id']: value, row_idx, col_name, status, source_details = self._extract_field_value(df, field, doc_type, sources_info=sources_info) if value: extracted_fields[field] = (value, row_idx, col_name, status, filename, source_details) # Специфичные поля для insurance if doc_type == 'insurance': for field in ['insurance_policy_number', 'insurance_date', 'insurance_company']: value, row_idx, col_name, status, source_details = self._extract_field_value(df, field, doc_type, sources_info=sources_info) if value: extracted_fields[field] = (value, row_idx, col_name, status, filename, source_details) # Специфичные поля для forwarder if doc_type == 'forwarder': for field in ['forwarder_name', 'forwarder_invoice_number', 'forwarder_date']: value, row_idx, col_name, status, source_details = self._extract_field_value(df, field, doc_type, sources_info=sources_info) if value: extracted_fields[field] = (value, row_idx, col_name, status, filename, source_details) # Заполняем шаблон извлеченными данными # Маппинг полей в шаблон с указанием колонки (PURCHASE, SALE, или обе) field_mapping_to_template = { # Поля только для PURCHASE 'supplier': ('Supplier', 'PURCHASE'), 'country_of_origin': ('Country of Origin', 'PURCHASE'), # Поля только для SALE 'off_taker': ('Off-taker', 'SALE'), 'country_of_destination': ('Country of Destination', 'SALE'), # Поля для обеих колонок (PURCHASE и SALE) - могут иметь разные значения 'amount': ('Amount, USD', 'BOTH'), 'quantity': ('Quantity, MT', 'BOTH'), 'unit_price': ('Price, USD', 'BOTH'), 'incoterm': ('Incoterm', 'BOTH'), 'product': ('Goods', 'BOTH'), # Product маппится в Goods 'payment_terms': ('Payment terms', 'BOTH'), 'payment_instrument': ('Payment instrument', 'BOTH'), # Общие поля (могут быть в обеих или отдельно) 'vessel_name': ('Name of vessel/forwarder/warehouse or any other', 'BOTH'), 'initial_stage': ('Initial stage of transaction', 'BOTH'), 'underlying_document': ('Underlying document', 'BOTH'), 'additional_notes': ('Additional Notes', 'BOTH'), } for field_key, (template_field, column_type) in field_mapping_to_template.items(): if field_key in extracted_fields: field_info = extracted_fields[field_key] if len(field_info) >= 6: value, row_idx, col_name, status, source_file, source_details = field_info[:6] elif len(field_info) >= 5: value, row_idx, col_name, status, source_file = field_info[:5] source_details = None else: value, row_idx, col_name, status, source_file = field_info source_details = None # Извлекаем информацию из source_details, если доступна source_page = None source_context = None extraction_method = 'standard' if source_details: source_page = source_details.get('page') or source_details.get('source_page') # Пробуем получить контекст из разных источников source_context = ( source_details.get('context') or source_details.get('source_context') or source_details.get('ocr_context') or '' ) extraction_method = source_details.get('method') or source_details.get('extraction_method', 'standard') # Если контекст пустой, пытаемся извлечь из sources_info if not source_context and sources_info: # Пробуем получить контекст из OCR данных if 'context' in sources_info: source_context = sources_info.get('context', '') elif 'ocr_raw_text' in sources_info: # Используем первые 200 символов OCR текста как контекст ocr_text = str(sources_info.get('ocr_raw_text', '')) if ocr_text: source_context = ocr_text[:200] + "..." if len(ocr_text) > 200 else ocr_text # Если страница не найдена, пытаемся извлечь из sources_info if source_page is None or source_page == -1: if sources_info: source_page = sources_info.get('page', -1) # Находим индекс строки в шаблоне template_idx = final_dataframe[final_dataframe['Field'] == template_field].index if len(template_idx) > 0: idx = template_idx[0] # Определяем, в какую колонку записывать значение if column_type == 'PURCHASE': final_dataframe.at[idx, 'PURCHASE'] = value final_dataframe.at[idx, 'PURCHASE Confidence'] = status * 50 if status <= 2 else 100 # Преобразуем статус в confidence # Добавляем метаданные для PURCHASE index_tuples.append((idx, 'PURCHASE')) source_data['source_file'].append(source_file) source_data['source_row'].append(row_idx) source_data['source_col'].append(col_name) source_data['status'].append(status) source_data['source_page'].append(source_page) source_data['source_context'].append(source_context or '') source_data['extraction_method'].append(extraction_method) elif column_type == 'SALE': final_dataframe.at[idx, 'SALE'] = value final_dataframe.at[idx, 'SALE Confidence'] = status * 50 if status <= 2 else 100 # Добавляем метаданные для SALE index_tuples.append((idx, 'SALE')) source_data['source_file'].append(source_file) source_data['source_row'].append(row_idx) source_data['source_col'].append(col_name) source_data['status'].append(status) source_data['source_page'].append(source_page) source_data['source_context'].append(source_context or '') source_data['extraction_method'].append(extraction_method) elif column_type == 'BOTH': # Для полей, которые могут быть в обеих колонках, определяем по контексту # Пока что записываем в обе колонки одинаковое значение # В будущем можно улучшить логику определения, в какую колонку идёт значение final_dataframe.at[idx, 'PURCHASE'] = value final_dataframe.at[idx, 'PURCHASE Confidence'] = status * 50 if status <= 2 else 100 final_dataframe.at[idx, 'SALE'] = value final_dataframe.at[idx, 'SALE Confidence'] = status * 50 if status <= 2 else 100 # Добавляем метаданные для обеих колонок for col in ['PURCHASE', 'SALE']: index_tuples.append((idx, col)) source_data['source_file'].append(source_file) source_data['source_row'].append(row_idx) source_data['source_col'].append(col_name) source_data['status'].append(status) source_data['source_page'].append(source_page) source_data['source_context'].append(source_context or '') source_data['extraction_method'].append(extraction_method) # Логируем найденное поле log_msg = f" Найдено поле '{template_field}': '{value}' из '{source_file}' в колонку {column_type}" if source_page is not None and source_page != -1: log_msg += f" (страница {source_page})" if row_idx >= 0: log_msg += f" [строка {row_idx}, колонка '{col_name}']" log_msg += f" методом {extraction_method}" logger.info(log_msg) # Заполняем метаданные для всех ячеек (только для колонок PURCHASE и SALE, не для Field и Confidence) data_columns = ['PURCHASE', 'SALE'] # Только эти колонки нуждаются в метаданных for row_idx in range(len(final_dataframe)): for col_name in data_columns: if (row_idx, col_name) not in index_tuples: index_tuples.append((row_idx, col_name)) source_data['source_file'].append('') source_data['source_row'].append(-1) source_data['source_col'].append('') source_data['status'].append(0) source_data['source_page'].append(None) source_data['source_context'].append('') source_data['extraction_method'].append('template') # Создаем DataFrame с метаданными (расширенная версия с детальной информацией) multi_index = pd.MultiIndex.from_tuples(index_tuples, names=['Row', 'Col']) metadata_df = pd.DataFrame({ 'source_file': source_data['source_file'], 'source_row': source_data['source_row'], 'source_col': source_data['source_col'], 'status': source_data['status'], 'source_page': source_data.get('source_page', [-1] * len(source_data['source_file'])), 'source_context': source_data.get('source_context', [''] * len(source_data['source_file'])), 'extraction_method': source_data.get('extraction_method', ['standard'] * len(source_data['source_file'])) }, index=multi_index) # Итоговое логирование purchase_fields_found = sum(1 for v in final_dataframe['PURCHASE'] if v and str(v).strip()) sale_fields_found = sum(1 for v in final_dataframe['SALE'] if v and str(v).strip()) fields_with_sources = sum(1 for sf in source_data['source_file'] if sf and sf.strip()) logger.info(f"Преобразование завершено: найдено {purchase_fields_found} полей в PURCHASE, {sale_fields_found} полей в SALE, из них {fields_with_sources} с известными источниками") # Если включена агрегация через LLM, используем её для улучшения результатов if self.use_llm_aggregation and self.ocr_handler: logger.info("Использую LLM для агрегации данных из всех источников...") return self._aggregate_with_llm(dataframes, final_dataframe, metadata_df) return final_dataframe, metadata_df def _aggregate_with_llm( self, dataframes: Dict[str, Union[pd.DataFrame, Tuple[pd.DataFrame, Optional[Dict]]]], initial_template: pd.DataFrame, initial_metadata: pd.DataFrame ) -> Tuple[pd.DataFrame, pd.DataFrame]: """ Агрегирует данные из всех источников через LLM для заполнения шаблона. :param dataframes: Словарь с данными из всех документов :param initial_template: Начальный шаблон (может быть частично заполнен) :param initial_metadata: Начальные метаданные :return: Заполненный шаблон и метаданные с источниками """ # Собираем все данные из всех источников all_extracted_data = [] for filename, df_or_tuple in dataframes.items(): if isinstance(df_or_tuple, tuple): df, sources_info = df_or_tuple else: df = df_or_tuple sources_info = None if df is None or df.empty: continue # Собираем данные из DataFrame doc_data = { 'file': filename, 'text_data': [], 'table_data': [], 'ocr_data': None } # Извлекаем текст if 'Content' in df.columns and 'Source' in df.columns: text_rows = df[df['Source'] == 'text'] for _, row in text_rows.iterrows(): doc_data['text_data'].append(row['Content']) # Извлекаем таблицы if 'Source' in df.columns: table_rows = df[df['Source'] == 'table'] for _, row in table_rows.iterrows(): # Собираем все колонки кроме служебных row_data = {k: v for k, v in row.items() if k not in ['Source', 'Page', 'Row']} if row_data: doc_data['table_data'].append(row_data) # Извлекаем OCR данные - используем полный вывод OCR # Собираем ВСЮ информацию из OCR - склеиваем все данные # ВАЖНО: включаем данные из изображений (image_sources) и обычные OCR данные ocr_data_parts = [] if sources_info: # Пробуем получить полный OCR вывод if 'ocr_raw_text' in sources_info: ocr_raw = str(sources_info['ocr_raw_text']).strip() if ocr_raw: ocr_data_parts.append(f"=== ПОЛНЫЙ OCR ТЕКСТ ===\n{ocr_raw}") if 'ocr_sources' in sources_info and sources_info['ocr_sources']: # Если есть OCR источники, собираем все данные for idx, ocr_source in enumerate(sources_info['ocr_sources']): if isinstance(ocr_source, dict): if 'text' in ocr_source: ocr_data_parts.append(f"=== OCR ИСТОЧНИК {idx + 1} ===\n{str(ocr_source['text'])}") # Добавляем другие поля из источника for key, value in ocr_source.items(): if key != 'text' and value and str(value).strip(): ocr_data_parts.append(f"{key}: {value}") # КРИТИЧЕСКИ ВАЖНО: добавляем данные из изображений (image_sources) if 'image_sources' in sources_info and sources_info['image_sources']: logger.info(f" Найдено {len(sources_info['image_sources'])} изображений в sources_info для {filename}") for idx, img_source in enumerate(sources_info['image_sources']): if isinstance(img_source, dict): page = img_source.get('page', '?') img_idx = img_source.get('image_index', '?') img_size = f"{img_source.get('width', '?')}x{img_source.get('height', '?')}" ocr_text = img_source.get('ocr_text', '') text_extracted = img_source.get('text_extracted', 0) status = img_source.get('status', 'unknown') if ocr_text and status == 'success': ocr_data_parts.append(f"=== OCR ИЗ ИЗОБРАЖЕНИЯ {idx + 1} (Страница {page}, Изображение {img_idx}, Размер: {img_size}, Извлечено строк: {text_extracted}) ===") ocr_data_parts.append(ocr_text) if len(ocr_text) > 500: # Если текст обрезан, добавляем пометку ocr_data_parts.append("(текст обрезан, полный текст доступен в DataFrame)") elif status != 'success': ocr_data_parts.append(f"=== ИЗОБРАЖЕНИЕ {idx + 1} (Страница {page}, Изображение {img_idx}, Размер: {img_size}) ===") ocr_data_parts.append(f"Статус: {status} (текст не извлечен)") # Также добавляем ВСЕ данные из DataFrame, которые могут быть из OCR # ВАЖНО: обрабатываем и 'ocr' и 'ocr_image' источники if 'Source' in df.columns: # Обрабатываем данные из OCR всего PDF ocr_rows = df[df['Source'] == 'ocr'] if not ocr_rows.empty: ocr_data_parts.append(f"=== OCR ДАННЫЕ ИЗ ВСЕГО PDF ({len(ocr_rows)} строк) ===") # Формируем таблицу из OCR данных for idx, row in ocr_rows.iterrows(): row_data = [] for col in df.columns: if col not in ['Source', 'Page', 'Column', 'Row']: value = row.get(col) if pd.notna(value) and str(value).strip() and str(value).strip().lower() != 'nan': row_data.append(f"{col}: {value}") if row_data: ocr_data_parts.append(f"Строка {idx}: {' | '.join(row_data)}") elif 'Content' in row: # Если есть только Content, используем его content = str(row['Content']).strip() if content and content.lower() != 'nan': ocr_data_parts.append(f"Строка {idx}: {content}") # КРИТИЧЕСКИ ВАЖНО: обрабатываем данные из изображений (ocr_image) ocr_image_rows = df[df['Source'] == 'ocr_image'] if not ocr_image_rows.empty: ocr_data_parts.append(f"=== OCR ДАННЫЕ ИЗ ИЗОБРАЖЕНИЙ ({len(ocr_image_rows)} строк) ===") logger.info(f" Найдено {len(ocr_image_rows)} строк данных из изображений для {filename}") # Группируем по изображениям для лучшей структуры image_groups = {} for idx, row in ocr_image_rows.iterrows(): page = row.get('Page', '?') img_idx = row.get('ImageIndex', '?') img_size = row.get('ImageSize', '?') key = f"Страница {page}, Изображение {img_idx}" if key not in image_groups: image_groups[key] = { 'page': page, 'image_index': img_idx, 'image_size': img_size, 'lines': [] } # Собираем все данные из строки row_data = [] content = str(row.get('Content', '')).strip() if content and content.lower() != 'nan': row_data.append(content) # Добавляем другие поля for col in df.columns: if col not in ['Source', 'Page', 'ImageIndex', 'ImageSize', 'Content', 'context']: value = row.get(col) if pd.notna(value) and str(value).strip() and str(value).strip().lower() != 'nan': row_data.append(f"{col}: {value}") if row_data: image_groups[key]['lines'].extend(row_data) # Формируем структурированный вывод для каждого изображения for key, img_data in image_groups.items(): ocr_data_parts.append(f"\n--- {key} (размер: {img_data['image_size']}) ---") for line in img_data['lines']: ocr_data_parts.append(f" {line}") logger.info(f" Обработано {len(image_groups)} изображений с данными") # Если есть другие колонки с данными, которые могут быть из OCR, добавляем их # Проверяем, есть ли в DataFrame данные, которые выглядят как OCR (например, таблицы) if not ocr_data_parts and not df.empty: # Если нет явных OCR данных, но DataFrame содержит структурированные данные, # добавляем их как потенциальные OCR данные df_str = df.to_string(index=False, max_rows=100) if df_str and len(df_str) > 50: # Только если есть достаточно данных ocr_data_parts.append(f"=== СТРУКТУРИРОВАННЫЕ ДАННЫЕ ===\n{df_str}") # Склеиваем все OCR данные в один большой блок if ocr_data_parts: doc_data['ocr_data'] = '\n\n'.join(ocr_data_parts) total_length = len(doc_data['ocr_data']) # Подсчитываем данные из разных источников image_count = len([p for p in ocr_data_parts if 'ИЗОБРАЖЕНИЯ' in p or 'ИЗОБРАЖЕНИЯ' in p.upper() or 'ИЗ ИЗОБРАЖЕНИЯ' in p]) pdf_ocr_count = len([p for p in ocr_data_parts if 'ВСЕГО PDF' in p]) logger.info(f" Собрано OCR данных для {filename}: {len(ocr_data_parts)} блоков, общая длина: {total_length} символов") if image_count > 0: logger.info(f" ✓ Включено данных из {image_count} изображений") if pdf_ocr_count > 0: logger.info(f" ✓ Включено данных из OCR всего PDF: {pdf_ocr_count} блоков") all_extracted_data.append(doc_data) # Формируем промпт для LLM prompt = self._build_aggregation_prompt(all_extracted_data, initial_template) # Проверяем, что промпт не пустой if not prompt or not prompt.strip(): logger.warning("Промпт для LLM агрегации пустой, пропускаю запрос к LLM") return initial_template, initial_metadata # Проверяем, что промпт содержит достаточно данных prompt_stripped = prompt.strip() if len(prompt_stripped) < 100: logger.warning(f"Промпт для LLM агрегации слишком короткий ({len(prompt_stripped)} символов), возможно нет данных для обработки") # Проверяем, что промпт содержит видимые символы if not any(c.isalnum() for c in prompt_stripped): logger.warning("Промпт не содержит видимых символов, пропускаю запрос к LLM") return initial_template, initial_metadata # Логируем промпт для отладки logger.info("=" * 60) logger.info("ПРОМПТ ДЛЯ LLM АГРЕГАЦИИ:") logger.info("=" * 60) logger.info(f"Длина промпта: {len(prompt)} символов") logger.info(prompt[:2000] + "..." if len(prompt) > 2000 else prompt) logger.info("=" * 60) # Отправляем в LLM через OCR handler (используем текстовый промпт, не файл) try: # Используем extract_text_with_ocr, но передаем данные как текст в промпте # Для этого создаем временный файл или используем другой подход response = self._call_llm_for_aggregation(prompt) if response: logger.info("=" * 60) logger.info("ОТВЕТ LLM:") logger.info("=" * 60) logger.info(response[:2000] + "..." if len(response) > 2000 else response) logger.info("=" * 60) # Парсим ответ LLM и заполняем шаблон return self._parse_llm_response(response, initial_template, initial_metadata, dataframes) else: logger.warning("LLM не вернул ответ, используем стандартное заполнение") return initial_template, initial_metadata except Exception as e: logger.error(f"Ошибка при агрегации через LLM: {e}") return initial_template, initial_metadata def _build_aggregation_prompt(self, all_extracted_data: List[Dict], template: pd.DataFrame) -> str: """Строит промпт для LLM агрегации с полным описанием всех полей""" prompt = """Ты - эксперт по извлечению структурированных данных из документов CTF (Commodity Trade Finance). Задача: заполнить шаблон заявки CTF на основе данных, извлеченных из различных документов (контракты, счета, страховые полисы, документы экспедитора). СТРУКТУРА ШАБЛОНА ЗАЯВКИ: Шаблон имеет структуру с двумя колонками: PURCHASE (покупка) и SALE (продажа). Некоторые поля заполняются только в одну колонку, некоторые - в обе. ШАБЛОН ЗАЯВКИ (все поля, которые нужно заполнить): """ # Добавляем шаблон со всеми полями и их текущими значениями for _, row in template.iterrows(): field_name = row['Field'] purchase_value = row.get('PURCHASE', '') if pd.notna(row.get('PURCHASE', '')) else '' sale_value = row.get('SALE', '') if pd.notna(row.get('SALE', '')) else '' # Определяем, в какие колонки идёт поле if field_name == 'Supplier': prompt += f"- {field_name} (только PURCHASE): {purchase_value if purchase_value else '(требуется заполнение)'}\n" elif field_name == 'Off-taker': prompt += f"- {field_name} (только SALE): {sale_value if sale_value else '(требуется заполнение)'}\n" elif field_name == 'Country of Origin': prompt += f"- {field_name} (только PURCHASE): {purchase_value if purchase_value else '(требуется заполнение)'}\n" elif field_name == 'Country of Destination': prompt += f"- {field_name} (только SALE): {sale_value if sale_value else '(требуется заполнение)'}\n" elif field_name in ['Name of vessel/forwarder/warehouse or any other', 'Initial stage of transaction', 'Underlying document', 'Additional Notes']: # Общие поля - могут быть в обеих колонках prompt += f"- {field_name} (общее, может быть в обеих колонках):\n" prompt += f" PURCHASE: {purchase_value if purchase_value else '(требуется заполнение)'}\n" prompt += f" SALE: {sale_value if sale_value else '(требуется заполнение)'}\n" else: # Поля для обеих колонок prompt += f"- {field_name} (для обеих колонок PURCHASE и SALE):\n" prompt += f" PURCHASE: {purchase_value if purchase_value else '(требуется заполнение)'}\n" prompt += f" SALE: {sale_value if sale_value else '(требуется заполнение)'}\n" prompt += "\n" + "="*80 + "\n" prompt += "ИЗВЛЕЧЕННЫЕ ДАННЫЕ ИЗ ДОКУМЕНТОВ:\n" prompt += "="*80 + "\n\n" # Добавляем данные из каждого документа (полностью, без ограничений) for doc_data in all_extracted_data: prompt += f"\n{'='*80}\n" prompt += f"ДОКУМЕНТ: {doc_data['file']}\n" prompt += f"{'='*80}\n" if doc_data['text_data']: prompt += "\n--- ТЕКСТ ИЗ ДОКУМЕНТА ---\n" for text in doc_data['text_data']: if text and str(text).strip(): prompt += f"{text}\n" if doc_data['table_data']: prompt += "\n--- ТАБЛИЦЫ ИЗ ДОКУМЕНТА ---\n" for idx, table_row in enumerate(doc_data['table_data']): prompt += f"Строка {idx + 1}:\n" for key, value in table_row.items(): if value and str(value).strip() and str(value).strip() != 'nan': prompt += f" {key}: {value}\n" if doc_data['ocr_data']: prompt += "\n--- ДАННЫЕ ИЗ OCR (ПОЛНЫЙ ВЫВОД - ВСЯ СОБРАННАЯ ИНФОРМАЦИЯ) ---\n" prompt += "ВКЛЮЧАЕТ:\n" prompt += "- Текст из OCR всего PDF\n" prompt += "- Текст из ВСЕХ изображений (ocr_image)\n" prompt += "- Структурированные данные из OCR\n" prompt += "---\n" # Передаем ВСЮ собранную OCR информацию - склеенную и структурированную ocr_content = str(doc_data['ocr_data']).strip() if ocr_content: prompt += ocr_content + "\n" logger.debug(f" Добавлено OCR данных для {doc_data['file']}: {len(ocr_content)} символов") prompt += "\n" prompt += "\n" + "="*80 + "\n" prompt += "ИНСТРУКЦИИ ПО ЗАПОЛНЕНИЮ:\n" prompt += "="*80 + "\n" prompt += """1. Внимательно проанализируй ВСЕ данные из всех документов 2. Заполни ВСЕ поля шаблона на основе извлеченных данных в соответствующие колонки PURCHASE и/или SALE 3. Если данные найдены в нескольких источниках, используй наиболее полные и точные 4. КРИТИЧЕСКИ ВАЖНО - структура с двумя колонками: - PURCHASE (покупка): данные о покупке товара (Supplier, Country of Origin, и общие поля) - SALE (продажа): данные о продаже товара (Off-taker, Country of Destination, и общие поля) - Некоторые поля (Amount, Quantity, Price, Incoterm, Goods, Payment terms, Payment instrument) могут иметь РАЗНЫЕ значения для PURCHASE и SALE - Supplier идет ТОЛЬКО в колонку PURCHASE - Off-taker идет ТОЛЬКО в колонку SALE - Country of Origin идет ТОЛЬКО в колонку PURCHASE - Country of Destination идет ТОЛЬКО в колонку SALE 5. Для каждого поля укажи: - purchase_value: значение для колонки PURCHASE (если применимо) - sale_value: значение для колонки SALE (если применимо) - source_file: имя файла-источника - source_method: метод извлечения (text/table/ocr) - purchase_confidence: уверенность для PURCHASE (0-100) - sale_confidence: уверенность для SALE (0-100) * 90-100: очень высокая уверенность (данные найдены явно и однозначно) * 70-89: высокая уверенность (данные найдены, но могут требовать проверки) * 50-69: средняя уверенность (данные извлечены, но неоднозначны) * 30-49: низкая уверенность (данные предположительные) * 1-29: очень низкая уверенность (данные сомнительные или отсутствуют) 6. Если поле не найдено, оставь значение пустым строкой "", а confidence установи в 0 7. Обрати особое внимание на: - Supplier: только в PURCHASE колонку - Off-taker: только в SALE колонку - Amount, Quantity, Price: могут быть РАЗНЫЕ значения для PURCHASE и SALE (цена покупки vs цена продажи) - Goods: описание товара (может быть одинаковым или разным для обеих колонок) - Payment terms/instrument: могут быть разные условия для покупки и продажи - Country of Origin: только в PURCHASE - Country of Destination: только в SALE - Общие поля (vessel, initial stage, underlying document, additional notes): могут быть в обеих колонках или только в одной ФОРМАТ ОТВЕТА (JSON): { "fields": { "Supplier": {"purchase_value": "GEORGIAN CONSTRUCTION SOLUTIONS LLC", "sale_value": "", "source_file": "...", "source_method": "...", "purchase_confidence": 95, "sale_confidence": 0}, "Off-taker": {"purchase_value": "", "sale_value": "SECA FOR CHEMICALS", "source_file": "...", "source_method": "...", "purchase_confidence": 0, "sale_confidence": 95}, "Amount, USD": {"purchase_value": "86100.00", "sale_value": "93674.44", "source_file": "...", "source_method": "...", "purchase_confidence": 95, "sale_confidence": 95}, "Quantity, MT": {"purchase_value": "140.00", "sale_value": "140.00", "source_file": "...", "source_method": "...", "purchase_confidence": 95, "sale_confidence": 95}, "Price, USD": {"purchase_value": "615.00", "sale_value": "669.10", "source_file": "...", "source_method": "...", "purchase_confidence": 95, "sale_confidence": 95}, "Incoterm": {"purchase_value": "CIF Alexandria", "sale_value": "CIF Alexandria", "source_file": "...", "source_method": "...", "purchase_confidence": 95, "sale_confidence": 95}, "Goods": {"purchase_value": "Geodmix A", "sale_value": "Geodmix A", "source_file": "...", "source_method": "...", "purchase_confidence": 95, "sale_confidence": 95}, "Payment terms": {"purchase_value": "ASAP", "sale_value": "SWB date + 150 days", "source_file": "...", "source_method": "...", "purchase_confidence": 95, "sale_confidence": 95}, "Payment instrument": {"purchase_value": "CAD", "sale_value": "Open Account", "source_file": "...", "source_method": "...", "purchase_confidence": 95, "sale_confidence": 95}, "Country of Origin": {"purchase_value": "Georgia", "sale_value": "", "source_file": "...", "source_method": "...", "purchase_confidence": 95, "sale_confidence": 0}, "Country of Destination": {"purchase_value": "", "sale_value": "Egypt", "source_file": "...", "source_method": "...", "purchase_confidence": 0, "sale_confidence": 95}, "Name of vessel/forwarder/warehouse or any other": {"purchase_value": "Loading Port - Poti, Vessel Claire A", "sale_value": "Loading Port - Poti, Vessel Claire A", "source_file": "...", "source_method": "...", "purchase_confidence": 95, "sale_confidence": 95}, "Initial stage of transaction": {"purchase_value": "post-shipment", "sale_value": "post-shipment", "source_file": "...", "source_method": "...", "purchase_confidence": 95, "sale_confidence": 95}, "Underlying document": {"purchase_value": "SWB + Packing list + certificate of origin + Certificate of Analysis", "sale_value": "SWB + Packing list + certificate of origin + Certificate of Analysis", "source_file": "...", "source_method": "...", "purchase_confidence": 95, "sale_confidence": 95}, "Additional Notes": {"purchase_value": "contracts and invoices are not signed, SWB issued to order of final offtaker, further due diligence needed for final Buyer due to open account sale, no third party quality analysis", "sale_value": "contracts and invoices are not signed, SWB issued to order of final offtaker, further due diligence needed for final Buyer due to open account sale, no third party quality analysis", "source_file": "...", "source_method": "...", "purchase_confidence": 90, "sale_confidence": 90} } } КРИТИЧЕСКИ ВАЖНО: - Верни ТОЛЬКО валидный JSON объект, без дополнительного текста, комментариев или markdown разметки - Для полей, которые идут только в одну колонку (Supplier, Off-taker, Country of Origin, Country of Destination), оставь другую колонку пустой - Для полей, которые могут быть в обеих колонках, заполни обе колонки (даже если значения одинаковые) - Обрати внимание, что Amount, Price могут быть РАЗНЫМИ для PURCHASE и SALE """ return prompt def _call_llm_for_aggregation(self, prompt: str) -> Optional[str]: """Вызывает LLM для агрегации данных с structured output""" if not self.ocr_handler: return None try: import requests import os api_key = self.ocr_handler.api_key if not api_key: logger.error("API ключ не установлен для LLM агрегации") return None # Проверяем, что промпт не пустой if not prompt or not prompt.strip(): logger.error("Промпт для LLM агрегации пустой, пропускаю запрос") return None # Убеждаемся, что промпт содержит видимые символы prompt = prompt.strip() if not any(c.isalnum() for c in prompt): logger.error("Промпт не содержит видимых символов, пропускаю запрос") return None headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "HTTP-Referer": "https://github.com/ctf-document-processor", # Опционально "X-Title": "CTF Document Processor", # Опционально } # Определяем JSON schema для structured output (обновлен для структуры с двумя колонками) # Примечание: schema не используется напрямую, но оставлен для документации json_schema = { "type": "object", "properties": { "fields": { "type": "object", "properties": { # Каждое поле теперь имеет purchase_value, sale_value, purchase_confidence, sale_confidence "Supplier": {"type": "object", "properties": { "purchase_value": {"type": "string"}, "sale_value": {"type": "string"}, "source_file": {"type": "string"}, "source_method": {"type": "string"}, "purchase_confidence": {"type": "integer", "minimum": 0, "maximum": 100}, "sale_confidence": {"type": "integer", "minimum": 0, "maximum": 100} }}, "Off-taker": {"type": "object", "properties": { "purchase_value": {"type": "string"}, "sale_value": {"type": "string"}, "source_file": {"type": "string"}, "source_method": {"type": "string"}, "purchase_confidence": {"type": "integer", "minimum": 0, "maximum": 100}, "sale_confidence": {"type": "integer", "minimum": 0, "maximum": 100} }}, "Amount, USD": {"type": "object", "properties": { "purchase_value": {"type": "string"}, "sale_value": {"type": "string"}, "source_file": {"type": "string"}, "source_method": {"type": "string"}, "purchase_confidence": {"type": "integer", "minimum": 0, "maximum": 100}, "sale_confidence": {"type": "integer", "minimum": 0, "maximum": 100} }} # Остальные поля аналогично... }, "required": [] } }, "required": ["fields"] } # Используем Claude, который поддерживает Zero data retention # Для текстовых запросов (без файлов) content должен быть строкой, не массивом # Согласно документации OpenRouter: для текста используется строка, для файлов - массив payload = { "model": "anthropic/claude-3.5-sonnet", "messages": [ { "role": "user", "content": prompt # Для текстовых запросов - просто строка, не массив } ] } # Логируем длину промпта перед отправкой logger.info(f"Отправляю запрос к LLM (длина промпта: {len(prompt)} символов, первые 200 символов: {prompt[:200]}...)") # Финальная проверка перед отправкой if not prompt or len(prompt.strip()) == 0: logger.error("КРИТИЧЕСКАЯ ОШИБКА: Промпт пустой перед отправкой запроса!") return None # Для Claude может поддерживаться json_schema через response_format # Но для надежности парсим JSON из ответа вручную logger.info("Отправляю запрос к LLM (anthropic/claude-3.5-sonnet) для агрегации данных...") response = requests.post( self.ocr_handler.base_url, headers=headers, json=payload, timeout=180 # Увеличиваем таймаут для больших промптов ) response.raise_for_status() result = response.json() if 'choices' in result and len(result['choices']) > 0: content = result['choices'][0]['message'].get('content', '') logger.info(f"Получен ответ от LLM (длина: {len(content)} символов)") return content return None except requests.exceptions.HTTPError as e: logger.error(f"HTTP ошибка при вызове LLM: {e}") if hasattr(e, 'response') and e.response is not None: try: error_detail = e.response.json() logger.error(f"Детали ошибки API: {error_detail}") # Проверяем, не связана ли ошибка с пустым контентом if 'text content blocks must be non-empty' in str(error_detail): logger.error("ОШИБКА: Отправлен пустой текстовый блок. Проверьте, что промпт не пустой.") except: logger.error(f"Статус ошибки: {e.response.status_code}") logger.error(f"Текст ответа: {e.response.text[:500]}") return None except Exception as e: logger.error(f"Критическая ошибка при вызове LLM: {e}", exc_info=True) return None def _parse_llm_response( self, response: str, template: pd.DataFrame, metadata: pd.DataFrame, dataframes: Dict ) -> Tuple[pd.DataFrame, pd.DataFrame]: """Парсит ответ LLM и заполняет шаблон с источниками""" try: # Извлекаем JSON из ответа (может быть в markdown, с комментариями и т.д.) import json import re # Убираем markdown код-блоки если есть response_clean = response.strip() if response_clean.startswith('```'): # Убираем ```json или ``` в начале и конце response_clean = re.sub(r'^```(?:json)?\s*', '', response_clean, flags=re.MULTILINE) response_clean = re.sub(r'\s*```$', '', response_clean, flags=re.MULTILINE) # Пытаемся найти JSON объект в ответе (ищем от первой { до последней }) json_match = re.search(r'\{.*\}', response_clean, re.DOTALL) if json_match: try: data = json.loads(json_match.group()) except json.JSONDecodeError: # Если не получилось, пробуем найти вложенный JSON # Ищем "fields" как ключевое слово fields_match = re.search(r'"fields"\s*:\s*\{[^}]*\}', response_clean, re.DOTALL) if fields_match: # Пробуем распарсить как часть JSON full_json_match = re.search(r'\{.*"fields".*\}', response_clean, re.DOTALL) if full_json_match: data = json.loads(full_json_match.group()) else: raise ValueError("Не удалось найти валидный JSON в ответе") else: raise ValueError("Не удалось найти JSON в ответе") else: # Пробуем распарсить весь ответ как JSON try: data = json.loads(response_clean) except json.JSONDecodeError: logger.error(f"Не удалось распарсить JSON из ответа LLM. Ответ: {response_clean[:500]}") return template, metadata if 'fields' not in data: logger.warning("LLM ответ не содержит поля 'fields'") return template, metadata # Заполняем шаблон fields_data = data['fields'] source_data = { 'source_file': [], 'source_row': [], 'source_col': [], 'status': [], 'source_page': [], 'source_context': [], 'extraction_method': [] } index_tuples = [] for field_name, field_info in fields_data.items(): # Получаем значения для обеих колонок purchase_value = field_info.get('purchase_value', '') sale_value = field_info.get('sale_value', '') source_file = field_info.get('source_file', '') source_method = field_info.get('source_method', 'llm_aggregation') purchase_confidence = field_info.get('purchase_confidence', 0) sale_confidence = field_info.get('sale_confidence', 0) # Нормализуем значения - убираем пробелы и проверяем на пустоту def normalize_value(val): if isinstance(val, str): val = val.strip() if not val or val.lower() in ['none', 'null', 'n/a', 'na']: return '' return str(val) if val else '' purchase_value = normalize_value(purchase_value) sale_value = normalize_value(sale_value) # Находим поле в шаблоне template_idx = template[template['Field'] == field_name].index if len(template_idx) > 0: idx = template_idx[0] # Заполняем значения в соответствующие колонки if purchase_value: template.at[idx, 'PURCHASE'] = purchase_value template.at[idx, 'PURCHASE Confidence'] = purchase_confidence # Добавляем метаданные для PURCHASE index_tuples.append((idx, 'PURCHASE')) source_data['source_file'].append(source_file) source_data['source_row'].append(-1) # LLM не дает точные координаты source_data['source_col'].append('') # Статус на основе уверенности: 0-29=2 (низкая), 30-69=1 (средняя), 70-100=0 (высокая) if purchase_confidence >= 70: status = 0 # Высокая уверенность elif purchase_confidence >= 30: status = 1 # Средняя уверенность else: status = 2 # Низкая уверенность source_data['status'].append(status) source_data['source_page'].append(None) source_data['source_context'].append('') source_data['extraction_method'].append(source_method) if sale_value: template.at[idx, 'SALE'] = sale_value template.at[idx, 'SALE Confidence'] = sale_confidence # Добавляем метаданные для SALE index_tuples.append((idx, 'SALE')) source_data['source_file'].append(source_file) source_data['source_row'].append(-1) source_data['source_col'].append('') if sale_confidence >= 70: status = 0 elif sale_confidence >= 30: status = 1 else: status = 2 source_data['status'].append(status) source_data['source_page'].append(None) source_data['source_context'].append('') source_data['extraction_method'].append(source_method) # Логируем заполнение log_parts = [] if purchase_value: log_parts.append(f"PURCHASE: '{purchase_value}' (conf: {purchase_confidence})") if sale_value: log_parts.append(f"SALE: '{sale_value}' (conf: {sale_confidence})") if log_parts: logger.info(f" LLM заполнил поле '{field_name}': {', '.join(log_parts)} из '{source_file}' методом {source_method}") # Обновляем метаданные для всех ячеек (только для колонок PURCHASE и SALE) data_columns = ['PURCHASE', 'SALE'] for row_idx in range(len(template)): for col_name in data_columns: if (row_idx, col_name) not in index_tuples: index_tuples.append((row_idx, col_name)) source_data['source_file'].append('') source_data['source_row'].append(-1) source_data['source_col'].append('') source_data['status'].append(0) source_data['source_page'].append(None) source_data['source_context'].append('') source_data['extraction_method'].append('template') # Создаем новый metadata_df multi_index = pd.MultiIndex.from_tuples(index_tuples, names=['Row', 'Col']) new_metadata_df = pd.DataFrame({ 'source_file': source_data['source_file'], 'source_row': source_data['source_row'], 'source_col': source_data['source_col'], 'status': source_data['status'], 'source_page': source_data['source_page'], 'source_context': source_data['source_context'], 'extraction_method': source_data['extraction_method'] }, index=multi_index) # Итоговое логирование purchase_fields_found = sum(1 for v in template['PURCHASE'] if v and str(v).strip()) sale_fields_found = sum(1 for v in template['SALE'] if v and str(v).strip()) logger.info(f"LLM агрегация завершена: заполнено {purchase_fields_found} полей в PURCHASE, {sale_fields_found} полей в SALE") return template, new_metadata_df except Exception as e: logger.error(f"Ошибка при парсинге ответа LLM: {e}") logger.error(f"Ответ LLM: {response[:500]}") return template, metadata