Compare commits

..

No commits in common. "d1daecdb31db069d68b75c06d934b4050d679727" and "721712d86a6d8f3dbb963c78098f1edd7517b86a" have entirely different histories.

5 changed files with 32 additions and 238 deletions

View File

@ -5,35 +5,17 @@ from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings from app.core.config import settings
# Без явных таймаутов зависший/протухший сокет к Postgres (например, тихо
# оборванное NAT/LB соединение после долгого простоя — ровно то, что могло
# случиться во время получасовой обработки батча из 230 файлов) блокирует
# asyncpg НАВСЕГДА: ни ошибки, ни лога, просто вечное ожидание чтения
# сокета. Это тот же класс проблемы с asyncio.run()-за-asyncio.run() вокруг
# общего пула соединений, что уже ловили раньше (см. комментарий про
# engine.dispose() в opencloud_token_store.py) — тут решаем его с другой
# стороны: даём зависшему соединению упасть с понятной ошибкой вместо
# бесконечного ожидания.
_CONNECT_TIMEOUT_SECONDS = 10
_COMMAND_TIMEOUT_SECONDS = 15
connect_args: dict = {} connect_args: dict = {}
if settings.DATABASE_SCHEMA: if settings.DATABASE_SCHEMA:
connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA} connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA}
if "sqlite" in settings.DATABASE_URL: if "sqlite" in settings.DATABASE_URL:
connect_args["check_same_thread"] = False connect_args["check_same_thread"] = False
else:
# timeout/command_timeout — параметры asyncpg.connect(), не SQLAlchemy;
# aiosqlite их не понимает, поэтому только для не-sqlite URL.
connect_args["timeout"] = _CONNECT_TIMEOUT_SECONDS
connect_args["command_timeout"] = _COMMAND_TIMEOUT_SECONDS
engine = create_async_engine( engine = create_async_engine(
settings.DATABASE_URL, settings.DATABASE_URL,
pool_pre_ping=True, pool_pre_ping=True,
pool_size=5, pool_size=5,
max_overflow=10, max_overflow=10,
pool_timeout=_CONNECT_TIMEOUT_SECONDS,
connect_args=connect_args, connect_args=connect_args,
) )

View File

@ -88,10 +88,6 @@ class _CompiledCandidate:
normalized: str normalized: str
short: str short: str
strict_scoped: bool strict_scoped: bool
# Собственные суффиксы normalized по границе ".", из двух и более
# сегментов: "a.b.c.d" -> ("b.c.d", "c.d"). Нужны _pick_value для поиска
# через словарь вместо прохода по всем полям строки (см. там).
dotted_suffixes: tuple[str, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
@ -121,10 +117,6 @@ class _PayloadLookup:
normalized_exact_first: dict[str, str] normalized_exact_first: dict[str, str]
normalized_suffix_first: dict[str, str] normalized_suffix_first: dict[str, str]
raw_short_first: dict[str, str] raw_short_first: dict[str, str]
# normalized_path (только составные, с ".") -> (позиция первого поля с
# непустым значением, значение). Позиция нужна, чтобы при нескольких
# совпадениях вернуть то же поле, что и прежний линейный проход.
dotted_path_first: dict[str, tuple[int, str]]
@dataclass(frozen=True) @dataclass(frozen=True)
@ -205,17 +197,11 @@ def _compile_candidates(candidates: tuple[str, ...]) -> tuple[_CompiledCandidate
other != normalized and other.endswith(f".{normalized}") other != normalized and other.endswith(f".{normalized}")
for other in normalized_candidates for other in normalized_candidates
), ),
dotted_suffixes=_dotted_suffixes(normalized),
) )
for candidate, normalized in zip(candidates, normalized_candidates) for candidate, normalized in zip(candidates, normalized_candidates)
) )
def _dotted_suffixes(normalized: str) -> tuple[str, ...]:
parts = normalized.split(".")
return tuple(".".join(parts[start:]) for start in range(1, len(parts) - 1))
def _build_rules(mapping_data: tuple[ColumnMeta, ...]) -> tuple[MappingRule, ...]: def _build_rules(mapping_data: tuple[ColumnMeta, ...]) -> tuple[MappingRule, ...]:
rules: list[MappingRule] = [] rules: list[MappingRule] = []
for meta in mapping_data: for meta in mapping_data:
@ -634,13 +620,10 @@ def _build_payload_lookup_from_entries(
normalized_exact_first: dict[str, str] = {} normalized_exact_first: dict[str, str] = {}
normalized_suffix_first: dict[str, str] = {} normalized_suffix_first: dict[str, str] = {}
raw_short_first: dict[str, str] = {} raw_short_first: dict[str, str] = {}
dotted_path_first: dict[str, tuple[int, str]] = {}
for position, entry in enumerate(entries): for entry in entries:
if not entry.value: if not entry.value:
continue continue
if "." in entry.normalized_path:
dotted_path_first.setdefault(entry.normalized_path, (position, entry.value))
normalized_exact_first.setdefault(entry.normalized_path, entry.value) normalized_exact_first.setdefault(entry.normalized_path, entry.value)
path_parts = entry.normalized_path.split(".") path_parts = entry.normalized_path.split(".")
for start_index in range(len(path_parts)): for start_index in range(len(path_parts)):
@ -656,7 +639,6 @@ def _build_payload_lookup_from_entries(
normalized_exact_first=normalized_exact_first, normalized_exact_first=normalized_exact_first,
normalized_suffix_first=normalized_suffix_first, normalized_suffix_first=normalized_suffix_first,
raw_short_first=raw_short_first, raw_short_first=raw_short_first,
dotted_path_first=dotted_path_first,
) )
@ -809,18 +791,13 @@ def _pick_value(
if value := lookup.normalized_suffix_first.get(candidate.normalized): if value := lookup.normalized_suffix_first.get(candidate.normalized):
return value return value
if not allow_short_lookup: if not allow_short_lookup:
# Ищем первое (в порядке полей) непустое составное поле, путь for entry in lookup.entries:
# которого является собственным суффиксом кандидата. Раньше это if (
# был проход по ВСЕМ полям строки с endswith на каждый кандидат entry.value
# каждой колонки — ~40% времени формирования отчёта (профиль на and "." in entry.normalized_path
# 123 реальных файлах). Теперь — несколько обращений к словарю. and candidate.normalized.endswith(f".{entry.normalized_path}")
best: tuple[int, str] | None = None ):
for suffix in candidate.dotted_suffixes: return entry.value
found = lookup.dotted_path_first.get(suffix)
if found is not None and (best is None or found[0] < best[0]):
best = found
if best is not None:
return best[1]
continue continue
if value := lookup.values.get(candidate.short): if value := lookup.values.get(candidate.short):
return value return value

View File

@ -164,16 +164,7 @@ class StreamingReportWriter:
self._text_format = self.workbook.add_format( self._text_format = self.workbook.add_format(
{"text_wrap": True, "valign": "top"} {"text_wrap": True, "valign": "top"}
) )
# Явный «обычный» формат для ячеек, которые раньше писались вообще
# без формата (например, невалидная дата в колонке дат): без него
# xlsxwriter подставил бы формат колонки (DD.MM.YYYY) из set_column.
self._plain_format = self.workbook.add_format()
self._column_formats = [self._resolve_format(column) for column in self.columns] self._column_formats = [self._resolve_format(column) for column in self.columns]
# Признак зависит только от имени колонки — считаем один раз, а не
# на каждую ячейку каждой строки.
self._text_identifier_flags = [
self._is_text_identifier_column(column) for column in self.columns
]
self._build_group_header_row() self._build_group_header_row()
# В режиме constant_memory строка "уходит" на диск сразу после того, # В режиме constant_memory строка "уходит" на диск сразу после того,
# как начата следующая — поэтому set_row для строки 0 нужно вызывать # как начата следующая — поэтому set_row для строки 0 нужно вызывать
@ -183,14 +174,10 @@ class StreamingReportWriter:
self.sheet.set_row(1, 46) self.sheet.set_row(1, 46)
for column_index, column_name in enumerate(self.columns): for column_index, column_name in enumerate(self.columns):
column_format = self._column_formats[column_index] column_format = self._column_formats[column_index]
# Формат колонки задаём на уровне колонки: пустые ячейки данных
# больше не пишутся (см. append_row), и вводимое вручную в пустую
# ячейку значение получит тот же формат, что и раньше.
self.sheet.set_column( self.sheet.set_column(
column_index, column_index,
column_index, column_index,
self._resolve_column_width(column_name, column_format), self._resolve_column_width(column_name, column_format),
column_format,
) )
# Шапка из двух строк уже настроена выше (до перехода на следующую # Шапка из двух строк уже настроена выше (до перехода на следующую
# строку — см. комментарий про constant_memory). # строку — см. комментарий про constant_memory).
@ -217,29 +204,20 @@ class StreamingReportWriter:
raw_value=raw_value, raw_value=raw_value,
default_format=default_format, default_format=default_format,
) )
# Пустые ячейки не пишем: в отчёте их ~70%, и каждая со стилем
# раздувала XML листа (15 МБ xlsx против 5 МБ на 123 файлах) и
# съедала ~40% времени записи. Внешне ничего не меняется — формат
# колонки задан в __init__ через set_column.
if value is None or value == "":
continue
if isinstance(value, datetime) and cell_format is self._date_format: if isinstance(value, datetime) and cell_format is self._date_format:
self.sheet.write_datetime( self.sheet.write_datetime(
self._next_data_row, zero_based_index, value, cell_format self._next_data_row, zero_based_index, value, cell_format
) )
elif self._text_identifier_flags[zero_based_index]: elif self._is_text_identifier_column(column_name):
identifier = self._normalize_identifier_string(
column_name,
value,
column_index=output_index,
row_by_index=data_by_index,
)
if identifier == "":
continue
self.sheet.write_string( self.sheet.write_string(
self._next_data_row, self._next_data_row,
zero_based_index, zero_based_index,
identifier, self._normalize_identifier_string(
column_name,
value,
column_index=output_index,
row_by_index=data_by_index,
),
default_format, default_format,
) )
elif cell_format is not None: elif cell_format is not None:
@ -247,9 +225,7 @@ class StreamingReportWriter:
self._next_data_row, zero_based_index, value, cell_format self._next_data_row, zero_based_index, value, cell_format
) )
else: else:
self.sheet.write( self.sheet.write(self._next_data_row, zero_based_index, value)
self._next_data_row, zero_based_index, value, self._plain_format
)
self._next_data_row += 1 self._next_data_row += 1
def _resolve_output_value( def _resolve_output_value(

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import tempfile import tempfile
import time import time
from collections.abc import Callable, Sequence from collections.abc import Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -28,13 +28,6 @@ from .report import ReportRow, StreamingReportWriter
EMPTY_BATCH_STATUS = "info" EMPTY_BATCH_STATUS = "info"
# Вызывается после каждого обработанного XML-файла: (номер файла, всего
# файлов, имя файла). Нужен, чтобы вызывающий код (main.py) мог показывать
# прогресс длинной обработки вместо одного блокирующего спиннера — см.
# комментарий в run_batch про причину (жалоба СФМ на зависание RAISA при
# батче 230 файлов).
ProgressCallback = Callable[[int, int, str], None]
@dataclass @dataclass
class BatchResult: class BatchResult:
@ -47,11 +40,6 @@ class BatchResult:
log_path: str log_path: str
messages: list[str] = field(default_factory=list) messages: list[str] = field(default_factory=list)
duration_seconds: float = 0.0 duration_seconds: float = 0.0
# Заполняются, только если файлы обработаны, но загрузка в OpenCloud не
# удалась: раньше в этом случае отчёт удалялся вместе с временной
# папкой, и 40 минут обработки большого батча пропадали впустую.
# main.py отдаёт их пользователю через st.download_button.
fallback_files: dict[str, bytes] = field(default_factory=dict)
def run_batch( def run_batch(
@ -59,7 +47,6 @@ def run_batch(
launcher: str, launcher: str,
opencloud_username: str | None, opencloud_username: str | None,
opencloud_access_token: str | None, opencloud_access_token: str | None,
progress_callback: ProgressCallback | None = None,
) -> BatchResult: ) -> BatchResult:
"""Прикреплённые XML и архивы -> отчёт/протокол/лог -> OpenCloud. """Прикреплённые XML и архивы -> отчёт/протокол/лог -> OpenCloud.
@ -75,14 +62,6 @@ def run_batch(
XML просто попадают в протокол со статусом "error" — никуда не XML просто попадают в протокол со статусом "error" — никуда не
перемещаются (перемещать их и некуда, это временная распакованная копия перемещаются (перемещать их и некуда, это временная распакованная копия
вложения). вложения).
progress_callback, если передан, вызывается после разбора каждого
XML-файла с (обработано_файлов, всего_файлов, имя_файла). Весь батч
по-прежнему выполняется синхронно в одном вызове (отдельный воркер/очередь
— отдельная задача), но колбэк даёт вызывающему коду возможность
обновлять прогресс по ходу обработки, а не показывать один спиннер на
всё время — именно отсутствие видимого прогресса на большом батче (230
файлов, тестирование СФМ) навело на мысль, что приложение "зависло".
""" """
started_at = time.perf_counter() started_at = time.perf_counter()
if not opencloud_username: if not opencloud_username:
@ -123,26 +102,6 @@ def run_batch(
messages=["Не прикреплено ни одного файла."], messages=["Не прикреплено ни одного файла."],
) )
# Доступ к OpenCloud проверяем ДО обработки, а не после: на батче из 230
# файлов СФМ получили HTTP 401 от OpenCloud только через ~40 минут
# обработки, когда дело дошло до загрузки. Неверный/отозванный App Token
# или несовпадение username должны всплывать сразу.
try:
opencloud_space_id = resolve_personal_drive_id(
opencloud_username, opencloud_access_token
)
except OpenCloudUploadError as exc:
return BatchResult(
status="error",
processed=0,
partial=0,
errors=1,
report_path="",
protocol_path="",
log_path="",
messages=[_describe_opencloud_access_error(exc, opencloud_username)],
)
protocol_rows: list[ProtocolRow] = [] protocol_rows: list[ProtocolRow] = []
attachment_failures: list[str] = [] attachment_failures: list[str] = []
attachment_names = ", ".join(path.name for path in input_paths) attachment_names = ", ".join(path.name for path in input_paths)
@ -150,15 +109,12 @@ def run_batch(
with tempfile.TemporaryDirectory() as extract_dir: with tempfile.TemporaryDirectory() as extract_dir:
extract_dir_path = Path(extract_dir) extract_dir_path = Path(extract_dir)
stage_started = time.perf_counter()
collected = collect_xml_attachments(input_paths, extract_dir_path) collected = collect_xml_attachments(input_paths, extract_dir_path)
extract_seconds = time.perf_counter() - stage_started
log_lines.append( log_lines.append(
f"{_now()} | вложений: {len(input_paths)} " f"{_now()} | вложений: {len(input_paths)} "
f"(архивов: {collected.archives}, " f"(архивов: {collected.archives}, "
f"отдельных XML: {collected.loose_xml_files}), " f"отдельных XML: {collected.loose_xml_files}), "
f"найдено XML-файлов: {len(collected.files)}, " f"найдено XML-файлов: {len(collected.files)}"
f"распаковка: {extract_seconds:.1f} с"
) )
for problem in collected.problems: for problem in collected.problems:
protocol_rows.append( protocol_rows.append(
@ -196,12 +152,7 @@ def run_batch(
) )
report_writer = StreamingReportWriter() report_writer = StreamingReportWriter()
total_files = len(collected.files) for collected_xml in collected.files:
total_rows_written = 0
parse_total_seconds = 0.0
write_total_seconds = 0.0
files_loop_started = time.perf_counter()
for file_number, collected_xml in enumerate(collected.files, start=1):
file_name = collected_xml.file_name file_name = collected_xml.file_name
try: try:
content = collected_xml.path.read_bytes() content = collected_xml.path.read_bytes()
@ -219,17 +170,12 @@ def run_batch(
) )
) )
log_lines.append(f"{_now()} | read failed: {file_name}. {exc}") log_lines.append(f"{_now()} | read failed: {file_name}. {exc}")
if progress_callback is not None:
progress_callback(file_number, total_files, file_name)
continue continue
# parse_xml_content сам проверяет имя файла и проставляет # parse_xml_content сам проверяет имя файла и проставляет
# fatal_error, если оно не соответствует шаблону — отдельная # fatal_error, если оно не соответствует шаблону — отдельная
# валидация здесь не нужна. # валидация здесь не нужна.
parse_started = time.perf_counter()
parse_result = parse_xml_content(file_name=file_name, xml_content=content) parse_result = parse_xml_content(file_name=file_name, xml_content=content)
parse_seconds = time.perf_counter() - parse_started
write_started = time.perf_counter()
for row in parse_result.rows: for row in parse_result.rows:
report_writer.append_row( report_writer.append_row(
ReportRow( ReportRow(
@ -241,10 +187,6 @@ def run_batch(
is_continuation=row.is_continuation, is_continuation=row.is_continuation,
) )
) )
write_seconds = time.perf_counter() - write_started
parse_total_seconds += parse_seconds
write_total_seconds += write_seconds
total_rows_written += len(parse_result.rows)
status, message = _resolve_parse_status(parse_result) status, message = _resolve_parse_status(parse_result)
protocol_rows.append( protocol_rows.append(
ProtocolRow( ProtocolRow(
@ -258,23 +200,7 @@ def run_batch(
processed_at=datetime.now(), processed_at=datetime.now(),
) )
) )
# Замер на каждый файл — чтобы по логу было видно, растёт ли log_lines.append(f"{_now()} | parsed {file_name}: {status}")
# время на файл по ходу батча (накопление) или его съедает
# конкретный тяжёлый файл (много операций/участников).
# 100 файлов = 6 мин, 230 файлов = 45 мин — нелинейно, причина
# пока не локализована.
log_lines.append(
f"{_now()} | parsed {file_name}: {status} | "
f"{file_number}/{total_files} | "
f"размер={len(content) // 1024} КБ, "
f"операций={parse_result.total_operations}, "
f"строк={len(parse_result.rows)}, "
f"разбор={parse_seconds:.2f} с, "
f"запись={write_seconds:.2f} с, "
f"всего строк в отчёте={total_rows_written}"
)
if progress_callback is not None:
progress_callback(file_number, total_files, file_name)
if not protocol_rows: if not protocol_rows:
protocol_rows.append( protocol_rows.append(
@ -290,14 +216,6 @@ def run_batch(
) )
log_lines.append(f"{_now()} | empty batch") log_lines.append(f"{_now()} | empty batch")
files_loop_seconds = time.perf_counter() - files_loop_started
log_lines.append(
f"{_now()} | обработка файлов: {files_loop_seconds:.1f} с "
f"(разбор XML: {parse_total_seconds:.1f} с, "
f"запись строк отчёта: {write_total_seconds:.1f} с), "
f"строк в отчёте: {total_rows_written}"
)
year, month, day, date_key = build_date_parts() year, month, day, date_key = build_date_parts()
with tempfile.TemporaryDirectory() as artifacts_dir: with tempfile.TemporaryDirectory() as artifacts_dir:
@ -306,24 +224,16 @@ def run_batch(
local_protocol = artifacts_dir_path / "protocol.xlsx" local_protocol = artifacts_dir_path / "protocol.xlsx"
local_log = artifacts_dir_path / "process.log" local_log = artifacts_dir_path / "process.log"
stage_started = time.perf_counter()
report_writer.save(local_report) report_writer.save(local_report)
report_save_seconds = time.perf_counter() - stage_started
stage_started = time.perf_counter()
write_protocol(protocol_rows, local_protocol) write_protocol(protocol_rows, local_protocol)
protocol_seconds = time.perf_counter() - stage_started
log_lines.append(
f"{_now()} | сохранение отчёта: {report_save_seconds:.1f} с "
f"({local_report.stat().st_size // 1024} КБ), "
f"протокол: {protocol_seconds:.1f} с"
)
local_log.write_text("\r\n".join(log_lines) + "\r\n", encoding="utf-8") local_log.write_text("\r\n".join(log_lines) + "\r\n", encoding="utf-8")
try: try:
# Номер запуска для папки дня в OpenCloud определяем по # Номер запуска для папки дня в OpenCloud определяем по
# самому OpenCloud — см. next_opencloud_run_number. # самому OpenCloud — см. next_opencloud_run_number.
# opencloud_space_id получен в начале run_batch (проверка opencloud_space_id = resolve_personal_drive_id(
# доступа до обработки). opencloud_username, opencloud_access_token
)
run_number = next_opencloud_run_number( run_number = next_opencloud_run_number(
opencloud_space_id, opencloud_space_id,
(DEFAULT_OUTPUT_DIR_NAME, year, month, day), (DEFAULT_OUTPUT_DIR_NAME, year, month, day),
@ -349,14 +259,6 @@ def run_batch(
run_number=run_number, run_number=run_number,
) )
except OpenCloudUploadError as exc: except OpenCloudUploadError as exc:
# Файлы ещё лежат во временной папке — забираем их в память,
# чтобы пользователь мог скачать результат из интерфейса.
fallback_name_suffix = date_key
fallback_files = {
f"report_{fallback_name_suffix}.xlsx": local_report.read_bytes(),
f"protocol_{fallback_name_suffix}.xlsx": local_protocol.read_bytes(),
f"process_{fallback_name_suffix}.log": local_log.read_bytes(),
}
return BatchResult( return BatchResult(
status="error", status="error",
processed=0, processed=0,
@ -366,13 +268,10 @@ def run_batch(
protocol_path="", protocol_path="",
log_path="", log_path="",
messages=[ messages=[
*log_lines,
f"Файлы обработаны, но не удалось загрузить результат " f"Файлы обработаны, но не удалось загрузить результат "
f"в OpenCloud: {_describe_opencloud_access_error(exc, opencloud_username)} " f"в OpenCloud: {exc}",
f"Отчёт можно скачать кнопками ниже.",
*log_lines[-5:],
], ],
duration_seconds=round(time.perf_counter() - started_at, 2),
fallback_files=fallback_files,
) )
processed = sum(1 for row in protocol_rows if row.status == "success") processed = sum(1 for row in protocol_rows if row.status == "success")
@ -477,18 +376,6 @@ def _upload_artifacts(
return report_url, protocol_url, log_url return report_url, protocol_url, log_url
def _describe_opencloud_access_error(exc: Exception, username: str) -> str:
text = str(exc)
if "HTTP 401" in text:
return (
f"OpenCloud отклонил App Token для пользователя «{username}» (HTTP 401). "
"Вероятно, токен истёк, отозван, введён с ошибкой или выпущен под "
"другой учётной записью. Создайте новый App Token в OpenCloud и "
"сохраните его через «Добавить токен»."
)
return f"Нет доступа к OpenCloud: {text}"
def _now() -> str: def _now() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S") return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

42
main.py
View File

@ -48,32 +48,14 @@ def main() -> None:
): ):
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
input_paths = _save_attachments(uploaded_files, Path(tmp_dir)) input_paths = _save_attachments(uploaded_files, Path(tmp_dir))
progress_bar = st.progress(0.0, text="Подготовка файлов...") with st.spinner("Выполняется обработка..."):
status_placeholder = st.empty() launcher_name = resolve_opencloud_username() or resolve_user_id()
batch_result = run_batch(
def _on_progress(done: int, total: int, file_name: str) -> None: input_paths=input_paths,
# Прогресс по файлам, а не один спиннер на весь батч — launcher=launcher_name,
# иначе при большом количестве файлов (были жалобы на opencloud_username=resolve_opencloud_username(),
# батч в 230 штук) непонятно, идёт обработка или opencloud_access_token=resolve_opencloud_access_token(),
# приложение зависло, и пользователь закрывает вкладку
# посреди обработки.
fraction = done / total if total else 1.0
progress_bar.progress(
fraction, text=f"Обработано {done} из {total}: {file_name}"
) )
status_placeholder.caption(
f"Последний обработанный файл: {file_name}"
)
launcher_name = resolve_opencloud_username() or resolve_user_id()
batch_result = run_batch(
input_paths=input_paths,
launcher=launcher_name,
opencloud_username=resolve_opencloud_username(),
opencloud_access_token=resolve_opencloud_access_token(),
progress_callback=_on_progress,
)
progress_bar.progress(1.0, text="Готово")
if batch_result.status == "success": if batch_result.status == "success":
st.success("Обработка завершена успешно.") st.success("Обработка завершена успешно.")
elif batch_result.status == "partial": elif batch_result.status == "partial":
@ -94,16 +76,6 @@ def main() -> None:
st.caption("Последние сообщения обработки:") st.caption("Последние сообщения обработки:")
for message in batch_result.messages: for message in batch_result.messages:
st.write(f"- {message}") st.write(f"- {message}")
# Если обработка прошла, а загрузка в OpenCloud — нет, отдаём
# результат напрямую, чтобы не терять долгую обработку.
for file_name, payload in batch_result.fallback_files.items():
st.download_button(
f"Скачать {file_name}",
data=payload,
file_name=file_name,
use_container_width=True,
key=f"fallback_{file_name}",
)
if st.button("Выйти", type="secondary"): if st.button("Выйти", type="secondary"):
clear_session() clear_session()