Skip to main content
Glama
AlanAAG

doc-extract

by AlanAAG

doc-extract

MCP-сервер, который делает ровно одну вещь: PDF на входе → весь документ в виде проверенного структурированного JSON на выходе.

Создан для этого рабочего процесса:

[1] User drops a document
[2] doc-extract MCP  ← this repo. Reads the WHOLE document, returns JSON
[3] DB node          → insert into NeonDB          (separate node)
[4] Agent node       → chats over the NeonDB content (separate node)
[5] Or: the team acts on the JSON directly, with no DB at all

Шаги 3, 4 и 5 намеренно не являются задачей этого сервера. У него нет драйвера базы данных, и каждый инструмент доступен только для чтения.

Никакой базы данных. Никаких побочных эффектов. Никакого сохранения состояния. Каждый инструмент только для чтения. Что происходит дальше — вставка, редактирование, маршрутизация, уведомление — это отдельный узел в рабочем процессе MagOneAI.


Область применения, закреплённая не только на словах

Этот сервер делает

Этот сервер НЕ делает

Читает весь текстовый слой PDF

Записывает в любую базу данных

Восстанавливает геометрию таблиц

Отправляет email или уведомления

Исправляет перенесённые ячейки

Редактирует или изменяет PDF

Проверяет прочитанное

Решает, что делать дальше

Возвращает JSON + координаты

Хранит что-либо между вызовами

Ограничение, чтобы расширение области было структурно сложным:

  • Все три инструмента аннотированы readOnlyHint: true, destructiveHint: false, idempotentHint: true. Оркестратор видит, что повторять безопасно.

  • extract() — чистая функция от байтов PDF. Одинаковый вход → одинаковый выход.

  • Перезагрузка профилей — это HTTP-административный маршрут, а не инструмент MCP. Изменения конфигурации — действие оператора; агент рабочего процесса не должен иметь возможности выбрать такое действие.

  • На диск ничего не записывается, кроме временного файла для входящего PDF.


Related MCP server: MCP PDF

Почему не OCR

Оба образца — это экспорты Crystal Reports из SAP Business One — встроенные шрифты, без растровых изображений. Каждый символ уже несёт точные координаты страницы. OCR растрировал бы это и заново выводил бы эти координаты с ошибкой.

Перенесённый BP Ref. No. — это проблема восстановления макета:

строка

токен

x0

x1

278.7

SI/08781/CN/

124

164

288.4

00007

124

142

00007 находится ровно на левом краю колонки BP Ref → та же ячейка → SI/08781/CN/00007.

Это важнее всего для файла Nutripharm, где фрагменты — голые цифры. При чтении плоского текста 111 правдоподобно склеивается с суммой, давая -8,762.513111. Координаты говорят x0=124, а не x≈450, значит это ссылка (N-CINV-01999111), а сумма остаётся -8,762.513.


Весь документ, всегда

Разбор профиля отвечает на вопрос «что такое позиции?» и игнорирует всё остальное. Этого недостаточно для шагов 2, 4 и 5, поэтому извлечение полного документа выполняется для каждого документа, независимо от соответствия профилю, и даёт четыре представления одного и того же содержимого:

Поле

Что это

Для чего использовать

content.markdown

Документ, отображаемый для LLM

Для чата. Сохраните это.

content.text

Простой текст

Поиск, эмбеддинги

content.key_values

Каждая пара Метка: значение на странице

Фильтры, поиск

content.blocks

Типизированные, упорядоченные, позиционированные блоки

Программное использование, редактирование

content.chunks

Markdown, разбитый по заголовкам

Поиск по длинным документам

tables[]

Каждая таблица как колонки + строки

Отображение, экспорт

line_items[], metadata{}

Типизированные + проверенные

SQL-агрегация

Документ без профиля больше не тупик. Он возвращает parsed_without_profile с полностью заполненным content — так что команда может сохранить его, общаться с ним и действовать, прежде чем кто-то напишет профиль. Профиль лишь добавляет типизированные позиции и перекрёстные проверки поверх.

Почему markdown — артефакт для чата

Агент, которому задан вопрос «каков исходящий баланс для One World?», отвечает гораздо надёжнее, читая это, чем собирая строки из JSON или сканируя сырой текстовый дамп:

# ONE WORLD TRADING L.L.C.

## Key fields
| Field | Value |
|---|---|
| supplier_code | S00066 |
| currency | AED |
| ageing_date | 2025-07-11 |

### Line items
| document_no | bp_reference_no | due_date | amount | running_balance |
|---|---|---|---|---|
| 131365 | SI/08781/CN/00007 | 2025-07-07 | -43160.25 | -43160.25 |
...

## All document fields (as printed)
| Posting Date | From To 11.07.25 |
| Sales Employee | No Sales Employee |
...

Типизированные, проверенные поля идут первыми. Сырые напечатанные поля следуют за ними, так что на вопрос, который профиль не моделирует, всё равно можно ответить. Разобранная таблица отображается один раз — она не дублируется как свободный текст.

Правило для узла 4: агрегации идут в SQL, «что говорит этот документ?» — в markdown. Одностраничная выписка целиком помещается в промпт, и подача её целиком лучше, чем извлечение фрагментов.

Контракт вывода

Потребители должны опираться на schema_version, а не на утиную типизацию.

{
  "schema_version": "2.0",
  "status": "ok",                     // ok | needs_review | parsed_without_profile
                                      // | profile_mismatch | no_text_layer | error
  "profile": "sap_b1_supplier_statement",
  "profile_confidence": 1.0,
  "document": { "file_name": "...", "checksum": "sha256:...",
                "pages": 1, "pages_parsed": [0] },
  "metadata": { "supplier_name": "ONE WORLD TRADING L.L.C.",
                "supplier_code": "S00066", "currency": "AED",
                "ageing_date": "2025-07-11" },
  "line_items": [
    { "line_no": 1, "document_type": "PU", "document_no": "131365",
      "bp_reference_no": "SI/08781/CN/00007",
      "posting_date": "2025-05-31", "due_date": "2025-07-07",
      "amount": -43160.25, "running_balance": -43160.25,
      "_source": {                    // only when include_coordinates=true
        "page": 0,
        "cells": { "BP Ref. No.": { "page": 0, "wrapped": true,
                                    "bbox": [123.7, 278.67, 163.79, 295.02] } }
      } }
  ],
  "summary":    { "buckets": { "Balance Due": -69966.75 } },
  "validation": { "ok": true, "checks": [ ... ] },
  "diagnostics": { "rows": 5, "rows_with_wrapped_cells": 1,
                   "column_fill_rate": { ... }, "page_geometry": [ ... ],
                   "warnings": [] }
}

checksum включён, чтобы нижестоящий узел вставки мог дедуплицировать без необходимости этому серверу знать о существовании базы данных. Это и есть разделение: мы предоставляем факт, кто-то другой решает, что с ним делать.

include_coordinates

По умолчанию выключено (примерно удваивает полезную нагрузку). Включайте, когда следующему узлу нужно редактировать, выделять или визуально проверять. bbox — это [x0, top, x1, bottom] в пунктах PDF и охватывает все строки, которые занимала перенесённая ячейка — так что поле редактирования над SI/08781/CN/00007 корректно покрывает обе визуальные строки.

Даты — ISO 8601. Суммы — числа с плавающей точкой, отрицательные для кредиторской задолженности, как напечатано.


Проверка и доказательство, что это работает

status: "ok" означает, что все проверки пройдены. Есть два независимых семейства:

Арифметические — воспроизводят ли прочитанные числа напечатанные?

  • running_balance_chain — каждый баланс увеличивается на сумму своей строки. Сильнее, чем итог: он называет строку, где ошибка, и ловит переставленные или дублированные строки, которые сумма вообще не видит.

  • sum_equals_last — суммы сходятся с исходящим балансом.

  • summary_equals_last — итог по срокам совпадает.

Структурные — поглотила ли реконструкция страницу?

  • word_coverage — каждое слово в области таблицы попало ровно в одну ячейку. Основной инвариант.

  • no_unassigned_words, no_orphan_lines — ничего не пропущено.

  • no_suspicious_rows — отмечает разреженные строки (фрагмент переноса, ошибочно принятый за новую строку) и строки, сшитые через разрыв страницы.

  • field_matches — проверка формы ссылочных номеров.

Структурные проверки существуют, потому что арифметика не видит искажения текста: искажённый ссылочный номер всё равно идеально сходится. tests/test_detection.py искажает данные семью способами и проверяет, что каждая проверка срабатывает:

PASS  clean data validates
PASS  misread amount on line 2      -> running_balance_chain, sum_equals_last
PASS  rows out of order             -> running_balance_chain
PASS  duplicated row                -> running_balance_chain
PASS  mangled reference number      -> field_matches:bp_reference_no   <-- ONLY this
PASS  unclaimed words on page       -> word_coverage, no_unassigned_words
PASS  summary disagrees             -> summary_equals_last
PASS  missing required field        -> required_fields

Строка 5 — суть всего упражнения. Проверка, которая никогда не срабатывает, — это украшение; эти проверки доказанно срабатывают.

Гарантия, которую стоит заявить заинтересованным сторонам, — не «парсер обрабатывает любой макет» — это неопровержимо, и кто-нибудь найдёт контрпример. Она такова: каждый документ либо разбирается и самопроверяется, либо помечается. Ничто не достигает следующего узла молча и с ошибкой.


Тестирование

В TESTING.md — полная лестница. Кратко:

bash scripts/check_repo.sh                      # is the clone complete?
bash scripts/run_tests.sh                       # all 6 suites, no server
npx @modelcontextprotocol/inspector python -m src.server   # see it as a client
python scripts/smoke_test.py <url> <token> doc.pdf         # verify a deployment

Уровень 3 в TESTING.md — поставить перед ним реального агента через Claude Desktop — тот, который не стоит пропускать. Docstring'и инструментов — единственные инструкции, которые агент MagOneAI когда-либо получит, и единственный способ их проверить — дать LLM попробовать их использовать.

Быстрый старт

pip install -r requirements.txt
export DOC_EXTRACT_TOKEN=$(openssl rand -hex 32)
MCP_TRANSPORT=http python -m src.server     # http://0.0.0.0:8000/mcp

python tests/test_samples.py                # parser regression
python tests/test_detection.py              # validation fires
python tests/e2e_http.py                    # real MCP client over HTTP
docker build -t doc-extract .
docker run -p 8000:8000 -e DOC_EXTRACT_TOKEN=$TOKEN doc-extract
curl localhost:8000/health

Как собрать MCP-сервер

Описано в BUILDING_AN_MCP_SERVER.md — как этот сервер устроен и почему: транспорты (почему потоковый HTTP, а не stdio), дизайн инструментов, docstring'и как промпты, аутентификация и подводные камни SDK 2.x.


Что свободно варьируется, а что требует правки профиля

Измерено, а не заявлено — tests/test_robustness.py мутирует формат по одной оси за раз.

Свободно. Изменений не требуется:

Вариация

Результат

Другой поставщик, суммы, даты

ok

Любое количество строк, на любом количестве страниц

ok

Глубина переноса 0, 1, 2, 4+ строк — смешанная в одном документе

ok

Колонки смещены из-за дрейфа макета

ok

Размер шрифта от 5pt до 16pt

ok

Пунктуация заголовка дрейфует (BP Ref. No. → BP Ref No)

ok

Другой префикс типа документа (PU → RC)

ok

Ложное жирное / отбрасывание тени

ok

Ничто не привязано к координате: полосы колонок перестраиваются для каждой страницы из её собственного заголовка, допуск кластеризации строк берётся из медианного размера глифа документа, а объединение ячеек заголовка — из распределения пробелов в самой строке, ограниченного размером шрифта.

Требует правки профиля — и сообщает об этом:

Вариация

Результат

Что вы получаете

Колонка переименована (Post. Date → Posting Date)

profile_mismatch

Отсутствующее имя + заголовок, как напечатано

Колонка удалена

profile_mismatch

То же

Колонка добавлена

needs_review

unmapped_header_columns: ["Currency"]

Якорь больше не совпадает (INV-2026-001)

needs_review

Ноль строк, помечено, а не пропущено пустым

Совершенно другой документ

parsed_without_profile

Полное содержимое, без типизированных строк

Случай с добавленной колонкой — самый важный: содержимое новой колонки поглощается соседней ячейкой, и арифметика всё ещё может сойтись. Поэтому all_header_columns_mapped явно проваливает документ, а не позволяет ему пройти молча.

В каждом из этих случаев content.markdown по-прежнему полон, так что документ остаётся сохраняемым и пригодным для чата, пока кто-то чинит профиль.

Ответ profile_mismatch непосредственно применим:

{
  "status": "profile_mismatch",
  "header_missing": "Post. Date",
  "header_actual": ["Document","BP Ref. No.","Posting Date","Due Date",
                    "Details","Amount","Balance"],
  "next_step": "Update its `columns` to the printed header, then
                POST /admin/reload-profiles."
}

Исправление — это изменение одной строки YAML и перезагрузка — без переразвёртывания.

Поведение на нескольких страницах

Прогон выписки — это не «та же страница N раз». Каждый из этих случаев проверяется в tests/test_multipage.py:

Сценарий

Результат

Заголовок повторяется на каждой странице

ok — все строки

Заголовок напечатан только на странице 1

ok — полосы переносятся вперёд

Строка разрезана разрывом страницы

ok — перенесённый хвост сшивается со своей строкой

Размер страницы / ориентация меняется в середине документа

ok — полосы перестраиваются для каждой страницы

Несвязанная страница (условия, платёж) вставлена

ok — пропускается, без выдуманных строк

Два из них потребовали реальных исправлений.

Заголовок только на странице 1 молча терял все строки после первой страницы. Теперь полосы предыдущей страницы переносятся вперёд — но только фиксируются, если страница действительно содержит строки, совпадающие с якорем, так что страница с условиями не натягивается на таблицу, к которой не имеет отношения. diagnostics. pages_without_repeated_header перечисляет страницы, к которым это применилось.

Строка, разрезанная разрывом страницы — ссылка SI/08781/CN/ внизу страницы 1 с 00007 вверху страницы 2 собирается в SI/08781/CN/00007. Сшивка ограничена тем, что перенесённая строка действительно находилась у нижнего края предыдущей страницы. Без этой защиты любая случайная строка над первой строкой страницы была бы приклеена к предыдущей строке; с ней случайный фрагмент вместо этого всплывает как сирота и проваливает документ:

status: needs_review
refs  : ['SI/2000', 'SI/2001', 'SI/2100']      <-- NOT corrupted
FAILED: no_orphan_lines  {'text': 'STRAY-FRAGMENT', 'reason': 'before_first_row'}

Правильная сшивка через разрыв страницы больше не требует ручной проверки сама по себе — она сообщается как предупреждение. Иначе каждую длинную выписку пришлось бы подписывать.

Добавление формата поставщика: конфигурация, а не код

Профили — это YAML в profiles/. Добавление формата никогда не затрагивает layout.py.

  1. extract_document возвращает parsed_without_profile

  2. probe_layout → каждая строка с координатами x для каждого слова

  3. Скопируйте заголовки столбцов дословно в columns

  4. Выберите anchor_column + anchor_pattern, соответствующие первой ячейке каждой строки и ничему другому

  5. Поместите файл в profiles/, POST /admin/reload-profiles

id: acme_invoice
detect:
  require: ["Tax Invoice"]
  text_contains: ["Tax Invoice", "Invoice No."]
table:
  columns: ["Line", "Item Code", "Description", "Qty", "Amount"]
  anchor_column: "Line"
  anchor_pattern: '^\d+$'
  stop_pattern: '^Subtotal\b'
  join_with: ""          # "" for codes/refs, " " for prose
fields:
  - {name: item_code, source: "Item Code", type: text}
  - {name: amount,    source: "Amount",    type: decimal}
validation:
  - {type: required_fields, fields: [item_code, amount]}

Типы: text, decimal, date (+format), int, token (+index). Сломанный YAML изолируется — он попадает в load_errors, остальные профили продолжают работать.


Подключение MagOneAI

[1] Trigger: user drops a document / Outlook attachment
        ↓
[2] Agent node: extract_document(source=<url>, file_name=...)
        ↓
    switch on status:
      ok                     -> [3] insert -> [4] chat agent
      needs_review           -> human approval -> insert / reject
      parsed_without_profile -> [3] insert anyway (content is complete)
                                 + alert: new vendor format seen
      no_text_layer          -> OCR queue
      error                  -> retry, then alert
        ↓
[3] DB node: run neon_schema.sql once, then upsert on document.checksum
        ↓
[4] Agent node with NeonDB access:
      "what does this say?"  -> SELECT markdown FROM documents WHERE ...
      "how much is past due?" -> SELECT SUM(amount) FROM v_document_lines ...

neon_schema.sql в этом репозитории содержит DDL, маппинг JSON-path → столбец для узла 3 и запросы, которые должен выполнять узел 4. Обратите внимание, что parsed_without_profile по-прежнему вставляет: content.markdown полный, поэтому документ сразу доступен для чата; типизированные позиции строк появляются позже, когда добавляется профиль, и повторная загрузка идемпотентна по контрольной сумме.

  • DOC_EXTRACT_TOKEN на стороне сервера, отправляется как Authorization: Bearer <token>.

  • max_iterations ≈ 15. Один вызов на счастливом пути; ветви ревью/онбординга добавляют больше вызовов.

  • Предпочитайте source_type="url"; base64 увеличивает полезную нагрузку ~33%.

  • Узел вставки владеет схемой. Этот сервер не знает о её существовании.


Инженерные заметки

  • Допуск кластеризации строк вычисляется для каждого документа на основе медианного размера глифа, а не жёстко задан, поэтому один и тот же отчёт в другом масштабе всё равно парсится. Это изменение выявило реальный баг: BP : и BP: токенизируются по-разному, поэтому регулярные выражения метаданных теперь работают с текстом, нормализованным по пунктуации.

  • Сшивка разрывов страниц — ячейка, переносящаяся через границу страницы, присоединяется к перенесённой строке и помечается stitched_across_page_break.

  • Обнаружение разреженных строк — строка, заполняющая ≤1/3 своих столбцов, помечается как возможный ложный якорь, единственный сбой, который не может поймать детектор сирот.

Известные ограничения

  • Если перенесённый фрагмент попал в столбец якоря и совпал с шаблоном якоря, он будет прочитан как новая строка. Обнаружение разреженных строк помечает вероятные случаи; надёжная защита — это строгий regex якоря.

  • Маппинг возрастных корзин проверен на двух документах, оба попадают в ближние корзины. Выполните запрос с реальным старением 90+ перед тем, как доверять меткам корзин в продакшене.

  • Зашифрованные или защищённые паролем PDF не обрабатываются; они отображаются как error.

Related MCP Connectors

Related MCP Servers