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
+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)