Добавил черновики накладных и OCR через Яндекс. LLM для расшифровки универсальный

This commit is contained in:
2025-12-17 03:38:24 +03:00
parent fda30276a5
commit e2df2350f7
32 changed files with 1785 additions and 214 deletions

74
ocr-service/llm_parser.py Normal file
View File

@@ -0,0 +1,74 @@
import os
import requests
import logging
import json
from typing import List
from parser import ParsedItem
logger = logging.getLogger(__name__)
YANDEX_GPT_URL = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion"
class YandexGPTParser:
def __init__(self):
self.folder_id = os.getenv("YANDEX_FOLDER_ID")
self.api_key = os.getenv("YANDEX_OAUTH_TOKEN") # Используем тот же доступ
def parse_with_llm(self, raw_text: str, iam_token: str) -> List[ParsedItem]:
"""
Отправляет текст в YandexGPT для структурирования.
"""
if not iam_token:
return []
prompt = {
"modelUri": f"gpt://{self.folder_id}/yandexgpt/latest",
"completionOptions": {
"stream": False,
"temperature": 0.1, # Низкая температура для точности
"maxTokens": "2000"
},
"messages": [
{
"role": "system",
"text": (
"Ты — помощник по бухгалтерии. Извлеки список товаров из текста документа. "
"Верни ответ строго в формате JSON: "
'[{"raw_name": string, "amount": float, "price": float, "sum": float}]. '
"Если количество не указано, считай 1.0. Не пиши ничего, кроме JSON."
)
},
{
"role": "user",
"text": raw_text
}
]
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {iam_token}",
"x-folder-id": self.folder_id
}
try:
response = requests.post(YANDEX_GPT_URL, headers=headers, json=prompt, timeout=30)
response.raise_for_status()
result = response.json()
# Извлекаем текст ответа
content = result['result']['alternatives'][0]['message']['text']
# Очищаем от возможных markdown-оберток ```json ... ```
clean_json = content.replace("```json", "").replace("```", "").strip()
items_raw = json.loads(clean_json)
parsed_items = [ParsedItem(**item) for item in items_raw]
return parsed_items
except Exception as e:
logger.error(f"LLM Parsing error: {e}")
return []
llm_parser = YandexGPTParser()

View File

@@ -1,4 +1,5 @@
import logging
import os
from typing import List
from fastapi import FastAPI, File, UploadFile, HTTPException
@@ -10,8 +11,10 @@ import numpy as np
from imgproc import preprocess_image
from parser import parse_receipt_text, ParsedItem
from ocr import ocr_engine
# Импортируем новый модуль
from qr_manager import detect_and_decode_qr, fetch_data_from_api
# Импортируем новый модуль
from yandex_ocr import yandex_engine
from llm_parser import llm_parser
logging.basicConfig(
level=logging.INFO,
@@ -19,10 +22,10 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
app = FastAPI(title="RMSER OCR Service (Hybrid: QR + OCR)")
app = FastAPI(title="RMSER OCR Service (Hybrid: QR + Yandex + Tesseract)")
class RecognitionResult(BaseModel):
source: str # 'qr_api' или 'ocr'
source: str # 'qr_api', 'yandex_vision', 'tesseract_ocr'
items: List[ParsedItem]
raw_text: str = ""
@@ -33,9 +36,10 @@ def health_check():
@app.post("/recognize", response_model=RecognitionResult)
async def recognize_receipt(image: UploadFile = File(...)):
"""
1. Попытка найти QR-код.
2. Если QR найден -> запрос к API -> возврат идеальных данных.
3. Если QR не найден -> Preprocessing -> OCR -> Regex Parsing.
Стратегия:
1. QR Code + FNS API (Приоритет 1 - Идеальная точность)
2. Yandex Vision OCR (Приоритет 2 - Высокая точность, если настроен)
3. Tesseract OCR (Приоритет 3 - Локальный фолбэк)
"""
logger.info(f"Received file: {image.filename}, content_type: {image.content_type}")
@@ -43,19 +47,18 @@ async def recognize_receipt(image: UploadFile = File(...)):
raise HTTPException(status_code=400, detail="File must be an image")
try:
# Читаем байты
# Читаем сырые байты
content = await image.read()
# Конвертируем в numpy для работы (нужен и для QR, и для OCR)
# Конвертируем в numpy для QR и локального препроцессинга
nparr = np.frombuffer(content, np.uint8)
# Оригинальное изображение (цветное/серое)
original_cv_image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if original_cv_image is None:
raise HTTPException(status_code=400, detail="Invalid image data")
# --- ЭТАП 1: QR Code Strategy ---
logger.info("Attempting QR code detection...")
logger.info("--- Stage 1: QR Code Detection ---")
qr_raw = detect_and_decode_qr(original_cv_image)
if qr_raw:
@@ -63,34 +66,63 @@ async def recognize_receipt(image: UploadFile = File(...)):
api_items = fetch_data_from_api(qr_raw)
if api_items:
logger.info(f"Successfully retrieved {len(api_items)} items via API.")
logger.info(f"Success: Retrieved {len(api_items)} items via QR API.")
return RecognitionResult(
source="qr_api",
items=api_items,
raw_text=f"QR Content: {qr_raw}"
)
else:
logger.warning("QR found but API failed to return items. Falling back to OCR.")
logger.warning("QR found but API failed. Falling back to OCR.")
else:
logger.info("QR code not found. Falling back to OCR.")
logger.info("QR code not found. Proceeding to OCR.")
# --- ЭТАП 2: OCR Strategy (Fallback) ---
# --- ЭТАП 2: Yandex Vision Strategy (Cloud OCR) ---
# Проверяем, настроен ли Яндекс
if yandex_engine.oauth_token and yandex_engine.folder_id:
logger.info("--- Stage 2: Yandex Vision OCR ---")
# Яндекс принимает сырые байты картинки (Base64), ему не нужен наш препроцессинг
yandex_text = yandex_engine.recognize(content)
if yandex_text and len(yandex_text) > 10:
logger.info(f"Yandex OCR success. Text length: {len(yandex_text)}")
logger.info(f"Yandex RAW OUTPUT:\n{yandex_text}")
yandex_items = parse_receipt_text(yandex_text)
logger.info(f"Parsed items preview: {yandex_items[:3]}...")
# Если Regex не нашел позиций (как в нашем случае со счетом)
if not yandex_items:
logger.info("Regex found nothing. Calling YandexGPT for semantic parsing...")
iam_token = yandex_engine._get_iam_token()
yandex_items = llm_parser.parse_with_llm(yandex_text, iam_token)
logger.info(f"Semantic parsed items preview: {yandex_items[:3]}...")
return RecognitionResult(
source="yandex_vision",
items=yandex_items,
raw_text=yandex_text
)
else:
logger.warning("Yandex Vision returned empty text or failed. Falling back to Tesseract.")
else:
logger.info("Yandex Vision credentials not set. Skipping Stage 2.")
# --- ЭТАП 3: Tesseract Strategy (Local Fallback) ---
logger.info("--- Stage 3: Tesseract OCR (Local) ---")
# 1. Image Processing (получаем бинарное изображение)
# Передаем исходные байты, так как функция внутри декодирует их заново
# (можно оптимизировать, но оставим совместимость с текущим кодом)
# 1. Image Processing (бинаризация, выравнивание)
processed_img = preprocess_image(content)
# 2. OCR
full_text = ocr_engine.recognize(processed_img)
tesseract_text = ocr_engine.recognize(processed_img)
# 3. Parsing
ocr_items = parse_receipt_text(full_text)
ocr_items = parse_receipt_text(tesseract_text)
return RecognitionResult(
source="ocr",
source="tesseract_ocr",
items=ocr_items,
raw_text=full_text
raw_text=tesseract_text
)
except Exception as e:

View File

@@ -1,87 +1,48 @@
Вот подробный системный промпт (System Definition), который описывает архитектуру, логику и контракт работы твоего OCR-сервиса.
Сохрани этот текст как **`SYSTEM_PROMPT.md`** или в документацию проекта (Confluence/Wiki). К нему стоит обращаться при разработке API-клиентов, тестировании или доработке логики.
---
# System Definition: RMSER OCR Service
# System Definition: RMSER OCR Service (v2.0)
## 1. Роль и Назначение
**RMSER OCR Service** — это специализированный микросервис на базе FastAPI, предназначенный для извлечения структурированных данных (товарных позиций) из изображений кассовых чеков РФ.
**RMSER OCR Service** — микросервис для интеллектуального извлечения товарных позиций из финансовых документов (чеки, счета, накладные).
Использует гибридный подход: QR-коды, Computer Vision и LLM (Large Language Models).
Сервис реализует **Гибридную Стратегию Распознавания**, отдавая приоритет получению верифицированных данных через ФНС, и используя оптическое распознавание (OCR) только как запасной вариант (fallback).
## 2. Логика Обработки (Pipeline)
## 2. Логика Обработки (Workflow)
### Этап А: Поиск QR-кода (Gold Standard)
1. Поиск QR-кода (`pyzbar`).
2. Валидация фискальных признаков (`t=`, `s=`, `fn=`).
3. Запрос к API ФНС (`proverkacheka.com`).
4. **Результат:** `source: "qr_api"`. 100% точность.
При получении `POST /recognize` с изображением, сервис выполняет действия в строгой последовательности:
### Этап Б: Yandex Cloud AI (Silver Standard)
*Запускается, если QR не найден.*
1. **OCR:** Отправка изображения в Yandex Vision OCR. Получение сырого текста.
2. **Primary Parsing:** Попытка извлечь данные регулярными выражениями.
3. **Semantic Parsing (LLM):** Если Regex не нашел позиций, текст отправляется в **YandexGPT**.
* Модель структурирует разрозненный текст в JSON.
* Исправляет опечатки, связывает количество и цену, разбросанные по документу.
4. **Результат:** `source: "yandex_vision"`. Высокая точность для любой верстки.
### Этап А: Поиск QR-кода (Priority 1)
1. **Детекция:** Сервис сканирует изображение на наличие QR-кода (библиотека `pyzbar`).
2. **Декодирование:** Извлекает сырую строку чека (формат: `t=YYYYMMDD...&s=SUM...&fn=...`).
3. **Запрос к API:** Отправляет сырые данные в API `proverkacheka.com` (или аналог).
4. **Результат:**
* Если API возвращает успех: Возвращает идеальный список товаров.
* **Метаданные ответа:** `source: "qr_api"`.
### Этап В: Локальный OCR (Bronze Fallback)
*Запускается при недоступности облака.*
1. Препроцессинг (OpenCV: Binarization, Deskew).
2. OCR (Tesseract).
3. Парсинг (Regex).
4. **Результат:** `source: "tesseract_ocr"`. Базовая точность.
### Этап Б: Оптическое Распознавание (Fallback Strategy)
*Запускается только если QR-код не найден или API вернул ошибку.*
## 3. Контракт API
1. **Препроцессинг (OpenCV):**
* Поиск контуров документа.
* Выравнивание перспективы (Perspective Warp).
* Бинаризация (Adaptive Threshold) для подготовки к Tesseract.
2. **OCR (Tesseract):** Извлечение сырого текста (rus+eng).
3. **Парсинг (Regex):**
* Поиск строк, содержащих паттерны цен (например, `120.00 * 2 = 240.00`).
* Привязка текстового описания (названия товара) к найденным ценам.
4. **Результат:** Возвращает список товаров, найденных эвристическим путем.
* **Метаданные ответа:** `source: "ocr"`.
## 3. Контракт API (Interface)
### Входные данные
* **Endpoint:** `POST /recognize`
* **Format:** `multipart/form-data`
* **Field:** `image` (binary file: jpg, png, heic, etc.)
### Выходные данные (JSON)
Сервис всегда возвращает объект `RecognitionResult`:
**POST /recognize** (`multipart/form-data`)
**Response (JSON):**
```json
{
"source": "qr_api", // или "ocr"
"source": "yandex_vision",
"items": [
{
"raw_name": "Молоко Домик в Деревне 3.2%", // Название товара
"amount": 2.0, // Количество
"price": 89.99, // Цена за единицу
"sum": 179.98 // Общая сумма позиции
},
{
"raw_name": "Пакет-майка",
"amount": 1.0,
"price": 5.00,
"sum": 5.00
"raw_name": "Маракуйя - пюре, 250 гр",
"amount": 5.0,
"price": 282.00,
"sum": 1410.00
}
],
"raw_text": "..." // Сырой текст (для отладки) или содержимое QR
}
```
## 4. Технический Стек и Зависимости
* **Runtime:** Python 3.10+
* **Web Framework:** FastAPI + Uvicorn
* **Computer Vision:** OpenCV (`cv2`) — обработка изображений.
* **OCR Engine:** Tesseract OCR 5 (`pytesseract`) — движок распознавания текста.
* **QR Decoding:** `pyzbar` + `libzbar0`.
* **External API:** `proverkacheka.com` (требует валидный токен).
## 5. Ограничения и Известные Проблемы
1. **Качество OCR:** В режиме `ocr` точность зависит от качества фото (освещение, помятость). Возможны ошибки в символах `3/8`, `1/7`, `З/3`.
2. **Зависимость от API:** Для работы режима `qr_api` необходим доступ в интернет и оплаченный токен провайдера.
3. **Скорость:** Режим `qr_api` работает быстрее (0.5-1.5 сек). Режим `ocr` может занимать 2-4 сек в зависимости от разрешения фото.
## 6. Инструкции для Интеграции
При встраивании сервиса в общую систему (например, Telegram-бот или Backend приложения):
1. Всегда проверяйте поле `source`. Если `source == "ocr"`, помечайте данные для пользователя как "Требующие проверки" (Draft). Если `source == "qr_api"`, данные можно считать верифицированными.
2. Если массив `items` пустой, значит сервис не смог распознать чек (ни QR, ни текст не прочитался). Предложите пользователю переснять фото.
"raw_text": "..."
}

137
ocr-service/yandex_ocr.py Normal file
View File

@@ -0,0 +1,137 @@
import os
import time
import json
import base64
import logging
import requests
from typing import Optional
logger = logging.getLogger(__name__)
IAM_TOKEN_URL = "https://iam.api.cloud.yandex.net/iam/v1/tokens"
VISION_URL = "https://ocr.api.cloud.yandex.net/ocr/v1/recognizeText"
class YandexOCREngine:
def __init__(self):
self.oauth_token = os.getenv("YANDEX_OAUTH_TOKEN")
self.folder_id = os.getenv("YANDEX_FOLDER_ID")
# Кэширование IAM токена
self._iam_token = None
self._token_expire_time = 0
if not self.oauth_token or not self.folder_id:
logger.warning("Yandex OCR credentials (YANDEX_OAUTH_TOKEN, YANDEX_FOLDER_ID) not set. Yandex OCR will be unavailable.")
def _get_iam_token(self) -> Optional[str]:
"""
Получает IAM-токен. Если есть живой кэшированный — возвращает его.
Если нет — обменивает OAuth на IAM.
"""
current_time = time.time()
# Если токен есть и он "свежий" (с запасом в 5 минут)
if self._iam_token and current_time < self._token_expire_time - 300:
return self._iam_token
logger.info("Obtaining new IAM token from Yandex...")
try:
response = requests.post(
IAM_TOKEN_URL,
json={"yandexPassportOauthToken": self.oauth_token},
timeout=10
)
response.raise_for_status()
data = response.json()
self._iam_token = data["iamToken"]
# Токен обычно живет 12 часов, но мы будем ориентироваться на поле expiresAt если нужно,
# или просто поставим таймер. Для простоты берем 1 час жизни кэша.
self._token_expire_time = current_time + 3600
logger.info("IAM token received successfully.")
return self._iam_token
except Exception as e:
logger.error(f"Failed to get IAM token: {e}")
return None
def recognize(self, image_bytes: bytes) -> str:
"""
Отправляет изображение в Yandex Vision и возвращает полный текст.
"""
if not self.oauth_token or not self.folder_id:
logger.error("Yandex credentials missing.")
return ""
iam_token = self._get_iam_token()
if not iam_token:
return ""
# 1. Кодируем в Base64
b64_image = base64.b64encode(image_bytes).decode("utf-8")
# 2. Формируем тело запроса
# Используем модель 'page' (для документов) и '*' для автоопределения языка
payload = {
"mimeType": "JPEG", # Yandex переваривает и PNG под видом JPEG часто, но лучше быть аккуратным.
# В идеале определять mime-type из файла, но JPEG - безопасный дефолт для фото.
"languageCodes": ["*"],
"model": "page",
"content": b64_image
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {iam_token}",
"x-folder-id": self.folder_id,
"x-data-logging-enabled": "true"
}
# 3. Отправляем запрос
try:
logger.info("Sending request to Yandex Vision OCR...")
response = requests.post(VISION_URL, headers=headers, json=payload, timeout=20)
# Если 401 Unauthorized, возможно токен протух раньше времени (редко, но бывает)
if response.status_code == 401:
logger.warning("Got 401 from Yandex. Retrying with fresh token...")
self._iam_token = None # сброс кэша
iam_token = self._get_iam_token()
if iam_token:
headers["Authorization"] = f"Bearer {iam_token}"
response = requests.post(VISION_URL, headers=headers, json=payload, timeout=20)
response.raise_for_status()
result_json = response.json()
# 4. Парсим ответ
# Структура: result -> textAnnotation -> fullText
# Или (если fullText нет) blocks -> lines -> text
text_annotation = result_json.get("result", {}).get("textAnnotation", {})
if not text_annotation:
logger.warning("Yandex returned success but no textAnnotation found.")
return ""
# Самый простой способ - взять fullText, он обычно склеен с \n
full_text = text_annotation.get("fullText", "")
if not full_text:
# Фолбэк: если fullText пуст, собираем вручную по блокам
logger.info("fullText empty, assembling from blocks...")
lines_text = []
for block in text_annotation.get("blocks", []):
for line in block.get("lines", []):
lines_text.append(line.get("text", ""))
full_text = "\n".join(lines_text)
return full_text
except Exception as e:
logger.error(f"Error during Yandex Vision request: {e}")
return ""
# Глобальный инстанс
yandex_engine = YandexOCREngine()