feat: add v3 extraction storage and structured parsing
This commit is contained in:
+159
-23
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user