feat: add v3 extraction storage and structured parsing

This commit is contained in:
Repinoid
2026-10-31 09:35:44 +03:00
parent 1878649317
commit 0a318aa006
6 changed files with 669 additions and 23 deletions
+114
View File
@@ -130,11 +130,125 @@ def _create_schema(conn):
created_at TEXT DEFAULT (datetime('now'))
);
-- Извлечённый паспорт хранится отдельно от исходного документа, чтобы
-- повторный запуск пайплайна не перезаписывал исходные байты и парсинг.
CREATE TABLE IF NOT EXISTS document_extractions (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL UNIQUE REFERENCES documents(id) ON DELETE CASCADE,
doc_class TEXT NOT NULL DEFAULT 'unknown',
intent_mode TEXT NOT NULL DEFAULT 'no_spec',
own_number TEXT,
own_date TEXT,
counterparty_name TEXT,
counterparty_inn TEXT,
referenced_numbers_json TEXT NOT NULL DEFAULT '[]',
sections_json TEXT NOT NULL DEFAULT '[]',
math_validation_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending',
prompt_version TEXT NOT NULL DEFAULT '',
raw_llm_response TEXT NOT NULL DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- Досье создаётся в рамках batch; отсутствие рамочного договора не
-- запрещает реконструкцию по спецификациям и дополнительным соглашениям.
CREATE TABLE IF NOT EXISTS contract_dossiers (
id TEXT PRIMARY KEY,
batch_id TEXT,
canonical_number TEXT NOT NULL,
alias_numbers_json TEXT NOT NULL DEFAULT '[]',
counterparty_name TEXT NOT NULL DEFAULT '',
counterparty_inn TEXT NOT NULL DEFAULT '',
has_frame_contract INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'ACTIVE',
created_at TEXT DEFAULT (datetime('now'))
);
-- Связь задаёт принятую принадлежность документа и сохраняет её основание.
-- UNIQUE предотвращает дубли при повторной сборке групп одного batch.
CREATE TABLE IF NOT EXISTS dossier_documents (
id TEXT PRIMARY KEY,
dossier_id TEXT NOT NULL REFERENCES contract_dossiers(id) ON DELETE CASCADE,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'supplement',
link_status TEXT NOT NULL DEFAULT 'CONFIRMED',
link_reason TEXT NOT NULL DEFAULT '',
seq_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE (dossier_id, document_id)
);
-- Идентичность услуги отделена от параметров и цены, которые меняются
-- по временным интервалам.
CREATE TABLE IF NOT EXISTS service_items (
id TEXT PRIMARY KEY,
dossier_id TEXT NOT NULL REFERENCES contract_dossiers(id) ON DELETE CASCADE,
service_family TEXT NOT NULL,
instance_anchor TEXT NOT NULL DEFAULT '',
canonical_name TEXT NOT NULL,
catalog_sku TEXT,
sku_status TEXT NOT NULL DEFAULT 'UNKNOWN',
created_at TEXT DEFAULT (datetime('now'))
);
-- Источник интервала каскадно удаляется вместе с документом. Документ,
-- закрывший интервал, очищается через SET NULL: сам интервал остаётся.
CREATE TABLE IF NOT EXISTS timeline_intervals (
id TEXT PRIMARY KEY,
dossier_id TEXT NOT NULL REFERENCES contract_dossiers(id) ON DELETE CASCADE,
service_item_id TEXT NOT NULL REFERENCES service_items(id) ON DELETE CASCADE,
charge_type TEXT NOT NULL DEFAULT 'recurring',
valid_from TEXT,
valid_to TEXT,
date_status TEXT NOT NULL DEFAULT 'explicit',
date_basis TEXT NOT NULL DEFAULT '',
raw_name TEXT NOT NULL,
config_params_json TEXT NOT NULL DEFAULT '{}',
price TEXT,
qty TEXT,
sum TEXT,
vat_mode TEXT NOT NULL DEFAULT 'with_vat',
source_document_id TEXT REFERENCES documents(id) ON DELETE CASCADE,
closed_by_document_id TEXT REFERENCES documents(id) ON DELETE SET NULL,
table_ref TEXT NOT NULL DEFAULT '',
quote TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'VERIFIED',
created_at TEXT DEFAULT (datetime('now'))
);
-- Исключения могут быть привязаны к разным этапам и разрешаться отдельно.
CREATE TABLE IF NOT EXISTS exceptions_queue (
id TEXT PRIMARY KEY,
batch_id TEXT,
dossier_id TEXT REFERENCES contract_dossiers(id) ON DELETE CASCADE,
document_id TEXT REFERENCES documents(id) ON DELETE CASCADE,
service_item_id TEXT REFERENCES service_items(id) ON DELETE SET NULL,
severity TEXT NOT NULL DEFAULT 'CONFLICT',
stage TEXT NOT NULL,
title TEXT NOT NULL,
source_quote TEXT NOT NULL DEFAULT '',
options_json TEXT NOT NULL DEFAULT '[]',
resolution_json TEXT,
status TEXT NOT NULL DEFAULT 'OPEN',
created_at TEXT DEFAULT (datetime('now')),
resolved_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_documents_batch ON documents(batch_id);
CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(batch_id, content_hash);
CREATE INDEX IF NOT EXISTS idx_supplements_contract ON supplements(contract_id);
CREATE INDEX IF NOT EXISTS idx_spec_current_contract ON spec_current(contract_id);
CREATE INDEX IF NOT EXISTS idx_spec_events_supplement ON spec_events(supplement_id);
CREATE INDEX IF NOT EXISTS idx_extractions_document ON document_extractions(document_id);
CREATE INDEX IF NOT EXISTS idx_dossiers_batch ON contract_dossiers(batch_id);
CREATE INDEX IF NOT EXISTS idx_dossier_documents_dossier ON dossier_documents(dossier_id);
CREATE INDEX IF NOT EXISTS idx_dossier_documents_document ON dossier_documents(document_id);
CREATE INDEX IF NOT EXISTS idx_service_items_dossier ON service_items(dossier_id);
CREATE INDEX IF NOT EXISTS idx_timeline_dossier ON timeline_intervals(dossier_id);
CREATE INDEX IF NOT EXISTS idx_timeline_service ON timeline_intervals(service_item_id);
CREATE INDEX IF NOT EXISTS idx_exceptions_dossier ON exceptions_queue(dossier_id);
CREATE INDEX IF NOT EXISTS idx_exceptions_batch ON exceptions_queue(batch_id);
""")
+113
View File
@@ -0,0 +1,113 @@
"""CRUD для досье договоров и доказанных связей с документами.
Досье идентифицируется внутренним UUID, а номер договора и его алиасы
сохраняются как изменяемые утверждения. Поэтому опечатки в документах
не становятся ключом базы и могут быть пересмотрены без переноса данных.
"""
import json
import uuid
from db.connection import execute, query, transaction
def _json_value(value, default):
"""Кодировать поля-списки, принимая уже сериализованный JSON без повтора."""
if value is None:
value = default
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def _decode_dossier(row):
"""Добавить к строке досье Python-представление списка алиасов."""
if not row:
return None
result = dict(row)
try:
result["alias_numbers"] = json.loads(result.get("alias_numbers_json") or "[]")
except (TypeError, json.JSONDecodeError):
result["alias_numbers"] = []
return result
def create_dossier(batch_id, canonical_number, alias_numbers=None,
counterparty_name="", counterparty_inn="",
has_frame_contract=False, status="ACTIVE"):
"""Создать досье и вернуть его запись со стабильным внутренним ID."""
dossier_id = str(uuid.uuid4())
execute(
"""INSERT INTO contract_dossiers
(id, batch_id, canonical_number, alias_numbers_json,
counterparty_name, counterparty_inn, has_frame_contract, status)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
(dossier_id, batch_id, canonical_number,
_json_value(alias_numbers, []), counterparty_name or "",
counterparty_inn or "", int(bool(has_frame_contract)), status),
)
return get_dossier(dossier_id)
def link_document(dossier_id, document_id, role="supplement",
link_status="CONFIRMED", link_reason="", seq_order=0):
"""Создать или обновить связь dossier-document идемпотентно.
Повторная сборка групп не должна создавать дубликаты. При уже имеющейся
связи сохраняется её ID, но актуальные роль, статус, основание и порядок
заменяются значениями нового решения группировщика.
"""
existing = query(
"SELECT id FROM dossier_documents WHERE dossier_id = %s AND document_id = %s",
(dossier_id, document_id),
)
if existing:
link_id = existing[0]["id"]
execute(
"""UPDATE dossier_documents
SET role = %s, link_status = %s, link_reason = %s, seq_order = %s
WHERE id = %s""",
(role, link_status, link_reason or "", seq_order, link_id),
)
else:
link_id = str(uuid.uuid4())
execute(
"""INSERT INTO dossier_documents
(id, dossier_id, document_id, role, link_status, link_reason, seq_order)
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
(link_id, dossier_id, document_id, role, link_status,
link_reason or "", seq_order),
)
rows = query("SELECT * FROM dossier_documents WHERE id = %s", (link_id,))
return rows[0] if rows else None
def list_dossiers_by_batch(batch_id):
"""Получить досье batch вместе с упорядоченным списком документов."""
dossiers = query(
"SELECT * FROM contract_dossiers WHERE batch_id = %s ORDER BY created_at, id",
(batch_id,),
)
result = []
for raw_dossier in dossiers:
dossier = _decode_dossier(raw_dossier)
dossier["documents"] = query(
"""SELECT dd.*, d.filename, d.doc_type, d.own_number, d.parent_number,
d.doc_date, d.counterparty, d.zip_source
FROM dossier_documents dd
JOIN documents d ON d.id = dd.document_id
WHERE dd.dossier_id = %s
ORDER BY dd.seq_order, d.created_at, d.id""",
(dossier["id"],),
)
result.append(dossier)
return result
def get_dossier(dossier_id):
"""Получить досье по внутреннему ID, не загружая список документов."""
rows = query("SELECT * FROM contract_dossiers WHERE id = %s", (dossier_id,))
return _decode_dossier(rows[0]) if rows else None
def clear_batch_dossiers(batch_id):
"""Удалить все досье batch; таблицы с ON DELETE CASCADE очистятся вместе с ними."""
with transaction():
return execute("DELETE FROM contract_dossiers WHERE batch_id = %s", (batch_id,))
+71
View File
@@ -0,0 +1,71 @@
"""CRUD для очереди исключений и сохранения решений оператора."""
import json
import uuid
from db.connection import execute, query
def add_exception(batch_id, dossier_id, document_id, service_item_id,
severity, stage, title, source_quote="", options=None):
"""Добавить конкретную нерешённую задачу и вернуть её запись."""
exception_id = str(uuid.uuid4())
options_json = json.dumps(options or [], ensure_ascii=False, default=str)
execute(
"""INSERT INTO exceptions_queue
(id, batch_id, dossier_id, document_id, service_item_id, severity,
stage, title, source_quote, options_json)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(exception_id, batch_id, dossier_id, document_id, service_item_id,
severity, stage, title, source_quote or "", options_json),
)
return get_exception(exception_id)
def get_exception(exception_id):
"""Получить исключение по ID и декодировать варианты решения."""
rows = query("SELECT * FROM exceptions_queue WHERE id = %s", (exception_id,))
if not rows:
return None
result = dict(rows[0])
try:
result["options"] = json.loads(result.get("options_json") or "[]")
except (TypeError, json.JSONDecodeError):
result["options"] = []
try:
result["resolution"] = json.loads(result.get("resolution_json") or "null")
except (TypeError, json.JSONDecodeError):
result["resolution"] = None
return result
def list_exceptions(batch_id=None, dossier_id=None, status=None):
"""Список исключений с необязательными фильтрами batch/dossier/status."""
clauses = []
params = []
for field, value in (("batch_id", batch_id), ("dossier_id", dossier_id),
("status", status)):
if value is not None:
clauses.append(f"{field} = %s")
params.append(value)
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
rows = query(
f"SELECT id FROM exceptions_queue{where} ORDER BY created_at, id",
params,
)
return [get_exception(row["id"]) for row in rows]
def resolve_exception(exception_id, resolution):
"""Сохранить решение без удаления исходной причины и цитаты.
Пересчёт зависимых данных выполняет сервисный слой; CRUD отвечает только
за аудируемую фиксацию решения человека.
"""
resolution_json = json.dumps(resolution, ensure_ascii=False, default=str)
changed = execute(
"""UPDATE exceptions_queue
SET resolution_json = %s, status = 'RESOLVED', resolved_at = datetime('now')
WHERE id = %s AND status = 'OPEN'""",
(resolution_json, exception_id),
)
return get_exception(exception_id) if changed else None
+111
View File
@@ -0,0 +1,111 @@
"""CRUD для результатов stateless-извлечения отдельных документов.
Исходный документ и результат LLM разделены намеренно: повторный запуск
извлечения может обновить паспорт, не затрагивая байты файла и его структуру.
Составные поля сохраняются как JSON-текст, поскольку SQLite не требует
отдельного JSON-типа и существующий слой БД уже использует такой подход.
"""
import json
import uuid
from db.connection import execute, query
_JSON_FIELDS = (
"referenced_numbers_json",
"sections_json",
"math_validation_json",
)
_FIELDS = (
"doc_class",
"intent_mode",
"own_number",
"own_date",
"counterparty_name",
"counterparty_inn",
*_JSON_FIELDS,
"status",
"prompt_version",
"raw_llm_response",
)
def _encode_json(value):
"""Сериализовать значения в JSON, сохраняя Decimal как точную строку."""
return json.dumps(value, ensure_ascii=False, default=str)
def _decode_row(row):
"""Вернуть составные поля паспорта как Python-объекты для сервисов."""
if not row:
return None
result = dict(row)
for field in _JSON_FIELDS:
raw_value = result.get(field)
try:
result[field.removesuffix("_json")] = json.loads(raw_value or "null")
except (TypeError, json.JSONDecodeError):
result[field.removesuffix("_json")] = None
return result
def upsert_extraction(document_id, data):
"""Создать или заменить паспорт документа, не меняя его стабильный ID.
Входные JSON-поля принимают как Python-списки/словари, так и уже
сериализованные строки. Поля, которых нет в `data`, получают безопасные
значения по умолчанию при вставке и остаются без изменений при обновлении.
"""
existing = query(
"SELECT id FROM document_extractions WHERE document_id = %s",
(document_id,),
)
values = {}
for field in _FIELDS:
if field not in data:
continue
value = data[field]
if field in _JSON_FIELDS and not isinstance(value, str):
value = _encode_json(value)
values[field] = value
if existing:
if values:
assignments = ", ".join(f"{field} = %s" for field in values)
params = [*values.values(), document_id]
execute(
f"UPDATE document_extractions SET {assignments}, "
"updated_at = datetime('now') WHERE document_id = %s",
params,
)
return get_by_document(document_id)
extraction_id = str(uuid.uuid4())
columns = ["id", "document_id", *values.keys()]
placeholders = ", ".join(["%s"] * len(columns))
execute(
f"INSERT INTO document_extractions ({', '.join(columns)}) "
f"VALUES ({placeholders})",
[extraction_id, document_id, *values.values()],
)
return get_by_document(document_id)
def get_by_document(document_id):
"""Получить паспорт документа или `None`, если он ещё не извлечён."""
rows = query(
"SELECT * FROM document_extractions WHERE document_id = %s",
(document_id,),
)
return _decode_row(rows[0]) if rows else None
def list_by_batch(batch_id):
"""Получить паспорта всех документов batch в порядке загрузки."""
rows = query(
"""SELECT e.* FROM document_extractions e
JOIN documents d ON d.id = e.document_id
WHERE d.batch_id = %s ORDER BY d.created_at, d.id""",
(batch_id,),
)
return [_decode_row(row) for row in rows]
+101
View File
@@ -0,0 +1,101 @@
"""CRUD для услуг и временных интервалов обязательств.
Денежные поля принимаются как десятичные строки. SQLite здесь отвечает за
хранение и фильтрацию дат, а арифметика остаётся в сервисном слое на Decimal.
"""
import uuid
from db.connection import execute, query, transaction
def insert_service_item(dossier_id, service_family, canonical_name,
instance_anchor="", catalog_sku=None,
sku_status="UNKNOWN"):
"""Создать устойчивую идентичность услуги в пределах досье."""
service_id = str(uuid.uuid4())
execute(
"""INSERT INTO service_items
(id, dossier_id, service_family, instance_anchor, canonical_name,
catalog_sku, sku_status)
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
(service_id, dossier_id, service_family, instance_anchor or "",
canonical_name, catalog_sku, sku_status),
)
return get_service_item(service_id)
def get_service_item(service_item_id):
"""Получить услугу по ID либо вернуть `None`."""
rows = query("SELECT * FROM service_items WHERE id = %s", (service_item_id,))
return rows[0] if rows else None
def insert_interval(data):
"""Создать интервал, сохраняя точные цены/количества строками.
`data` должен содержать dossier_id, service_item_id и raw_name; остальные
поля берутся из значений по умолчанию схемы либо передаются явно.
"""
interval_id = str(uuid.uuid4())
fields = (
"dossier_id", "service_item_id", "charge_type", "valid_from", "valid_to",
"date_status", "date_basis", "raw_name", "config_params_json", "price",
"qty", "sum", "vat_mode", "source_document_id", "closed_by_document_id",
"table_ref", "quote", "status",
)
values = [data[field] for field in fields if field in data]
selected_fields = [field for field in fields if field in data]
columns = ["id", *selected_fields]
placeholders = ", ".join(["%s"] * len(columns))
execute(
f"INSERT INTO timeline_intervals ({', '.join(columns)}) VALUES ({placeholders})",
[interval_id, *values],
)
rows = query("SELECT * FROM timeline_intervals WHERE id = %s", (interval_id,))
return rows[0] if rows else None
def close_interval(interval_id, valid_to, closed_by_document_id=None):
"""Закрыть интервал полуоткрытой границей и указать закрывший документ."""
return execute(
"""UPDATE timeline_intervals
SET valid_to = %s, closed_by_document_id = %s
WHERE id = %s""",
(valid_to, closed_by_document_id, interval_id),
)
def list_timeline(dossier_id, as_of_date=None):
"""Получить историю либо активный на дату срез по досье.
Для среза используется единая полуоткрытая семантика: начало включено,
окончание исключено. NULL-дата начала не считается подтверждённо активной.
"""
if as_of_date:
return query(
"""SELECT * FROM timeline_intervals
WHERE dossier_id = %s AND valid_from <= %s
AND (valid_to IS NULL OR valid_to > %s)
ORDER BY valid_from, service_item_id, id""",
(dossier_id, as_of_date, as_of_date),
)
return query(
"""SELECT * FROM timeline_intervals WHERE dossier_id = %s
ORDER BY valid_from, service_item_id, id""",
(dossier_id,),
)
def reset_dossier_timeline(dossier_id):
"""Сбросить вычисленный таймлайн и открытые исключения сопоставления.
Решённые исключения сохраняются как аудит пользовательского решения;
нерешённые lineage-исключения пересоздаются вместе с новым построением.
"""
with transaction():
execute(
"""DELETE FROM exceptions_queue
WHERE dossier_id = %s AND stage = 'lineage_matching' AND status = 'OPEN'""",
(dossier_id,),
)
return execute("DELETE FROM service_items WHERE dossier_id = %s", (dossier_id,))
+159 -23
View File
@@ -1,4 +1,9 @@
"""Parse service — PDF (pdfplumber), DOCX (python-docx), plain text fallback."""
"""Структурный парсер PDF/DOCX с порядком чтения и контекстом таблиц.
Формат elements_json остаётся совместимым с текущей загрузкой: верхнеуровневый
status по-прежнему только `parsed` или `error`. Новые метаданные добавляются
в элементы, чтобы выбор файлов и отображение результата загрузки не менялись.
"""
import io
@@ -19,37 +24,126 @@ def parse_file(filename, data):
def _parse_pdf(data):
"""Parse PDF with pdfplumber — preserves table structure (unlike PyPDF2)."""
"""Извлечь PDF в порядке страницы, привязав предшествующий текст к таблицам.
Координаты pdfplumber позволяют отделить заголовки таблиц от текста в её
ячейках и расположить абзацы/таблицы в порядке чтения. Для сканов без
текстового слоя возвращается parsed с отдельным флагом, чтобы существующий
upload-контракт не трактовал нечитаемый файл как ошибку передачи.
"""
import pdfplumber
elements = []
table_index = 0
with pdfplumber.open(io.BytesIO(data)) as pdf:
for page in pdf.pages:
# Tables first — preserves column structure critical for specs
tables = page.extract_tables()
for table in tables:
if table:
rows = []
for row in table:
if row:
cells = [str(cell or "").strip() for cell in row]
if any(cells):
rows.append(cells)
if rows:
elements.append({"type": "table", "rows": rows})
# Remaining text as paragraphs
text = page.extract_text()
if text:
for line in text.split("\n"):
line = line.strip()
if line:
elements.append({"type": "paragraph", "text": line, "style": ""})
return {"status": "parsed", "element_count": len(elements), "elements": elements}
table_events = []
table_regions = []
for table in page.find_tables():
rows = _clean_table_rows(table.extract())
if not rows:
continue
table_index += 1
x0, top, x1, bottom = table.bbox
table_regions.append((x0, top, x1, bottom))
table_events.append((float(top), {
"type": "table",
"table_index": table_index,
"rows": rows,
}))
word_events = _pdf_paragraph_events(page, table_regions)
events = [(top, 0, element) for top, element in word_events]
events.extend((top, 1, element) for top, element in table_events)
events.sort(key=lambda event: (event[0], event[1]))
preceding_paragraphs = []
for _top, _kind, element in events:
if element["type"] == "paragraph":
elements.append(element)
preceding_paragraphs.append(element["text"])
preceding_paragraphs = preceding_paragraphs[-4:]
else:
element["preceding_paragraphs"] = list(preceding_paragraphs)
elements.append(element)
preceding_paragraphs = []
result = {"status": "parsed", "element_count": len(elements), "elements": elements}
if not elements:
result["is_unreadable_scan"] = True
return result
def _clean_table_rows(table):
"""Нормализовать ячейки таблицы, удаляя только полностью пустые строки."""
rows = []
for row in table or []:
if not row:
continue
cells = [str(cell or "").strip() for cell in row]
if any(cells):
rows.append(cells)
return rows
def _pdf_paragraph_events(page, table_regions):
"""Сгруппировать слова PDF в строки вне bbox таблиц.
Каждая строка получает верхнюю координату для общей сортировки с таблицами.
Если PDF-библиотека смогла получить текст страницы, но не вернула слова,
весь текст сохраняется одним абзацем вместо ошибочной пометки «скан».
"""
words = page.extract_words() or []
visible_words = []
for word in words:
x0 = float(word.get("x0", 0))
x1 = float(word.get("x1", x0))
top = float(word.get("top", 0))
bottom = float(word.get("bottom", top))
center_x = (x0 + x1) / 2
center_y = (top + bottom) / 2
inside_table = any(
left <= center_x <= right and upper <= center_y <= lower
for left, upper, right, lower in table_regions
)
text = str(word.get("text", "")).strip()
if text and not inside_table:
visible_words.append((top, x0, text))
visible_words.sort(key=lambda item: (item[0], item[1]))
lines = []
for top, x0, text in visible_words:
if not lines or abs(top - lines[-1]["top"]) > 3:
lines.append({"top": top, "words": []})
lines[-1]["words"].append((x0, text))
events = []
for line in lines:
line_text = " ".join(text for _x0, text in sorted(line["words"]))
if line_text:
events.append((line["top"], {
"type": "paragraph", "text": line_text, "style": "",
}))
if events:
return events
if words and table_regions:
return []
text = page.extract_text()
if not text:
return []
return [
(0.0, {"type": "paragraph", "text": line.strip(), "style": ""})
for line in text.split("\n") if line.strip()
]
def _parse_docx(data):
"""Извлечь DOCX, сохранив порядок блоков и абзацы перед каждой таблицей."""
import docx
doc = docx.Document(io.BytesIO(data))
elements = []
preceding_paragraphs = []
table_index = 0
for block in doc.element.body:
tag = block.tag.split("}")[-1] if "}" in block.tag else block.tag
@@ -57,6 +151,8 @@ def _parse_docx(data):
text = _extract_paragraph_text(block)
if text:
elements.append({"type": "paragraph", "text": text, "style": ""})
preceding_paragraphs.append(text)
preceding_paragraphs = preceding_paragraphs[-4:]
elif tag == "tbl":
rows = []
for tr in block.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tr"):
@@ -67,7 +163,14 @@ def _parse_docx(data):
if cells:
rows.append(cells)
if rows:
elements.append({"type": "table", "rows": rows})
table_index += 1
elements.append({
"type": "table",
"table_index": table_index,
"preceding_paragraphs": list(preceding_paragraphs),
"rows": rows,
})
preceding_paragraphs = []
return {"status": "parsed", "element_count": len(elements), "elements": elements}
@@ -85,3 +188,36 @@ def _parse_text(data):
lines = [l.strip() for l in text.split("\n") if l.strip()]
return {"status": "parsed", "element_count": len(lines),
"elements": [{"type": "paragraph", "text": l, "style": ""} for l in lines]}
def format_elements_with_structure(elements):
"""Отформатировать элементы документа для stateless-промпта.
Табличный контекст продублирован в заголовке таблицы намеренно: extractor
получает локальную связь периода/итога с таблицей, даже если его окно
внимания не удерживает длинный юридический текст перед ней.
"""
lines = []
paragraph_index = 0
table_index = 0
for element in elements or []:
if not isinstance(element, dict):
continue
if element.get("type") == "paragraph":
text = str(element.get("text") or "").strip()
if text:
paragraph_index += 1
lines.append(f"[ПАРАГРАФ p{paragraph_index}] {text}")
elif element.get("type") == "table":
table_index += 1
table_ref = element.get("table_index") or table_index
context = " | ".join(
str(value).strip()
for value in element.get("preceding_paragraphs", [])
if str(value).strip()
)
lines.append(f"[ТАБЛИЦА #{table_ref} | Контекст над таблицей: {context!r}]")
for row_index, row in enumerate(element.get("rows", []), start=1):
cells = [str(cell or "").strip() for cell in (row or [])]
lines.append(f" [Строка {table_ref}.{row_index}] " + " | ".join(cells))
return "\n".join(lines)