Files
group_fqcd_jr/app/service/offsite_document_recognition_adapter.py
T

866 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""场外基金附件 OCR 与字段识别适配层。"""
from __future__ import annotations
import asyncio
import base64
import json
import re
import time
import zipfile
from collections.abc import Mapping
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from importlib import import_module, util
from io import BytesIO
from pathlib import Path
from typing import Any, Literal, cast
from xml.etree import ElementTree
import httpx
from app.core.config import Settings
from app.core.offsite_fund_contracts import DocumentType
RecognitionStatus = Literal["mock", "success", "disabled", "misconfigured", "error"]
REQUIRED_FIELDS: dict[str, tuple[str, ...]] = {
"subscription": (
"基金代码",
"基金名称",
"账户标识",
"申请编号",
"申请日期",
"代销机构",
"申购金额",
"金额单位",
),
"redemption": (
"基金代码",
"基金名称",
"账户标识",
"申请编号",
"申请日期",
"代销机构",
"赎回份额",
),
}
EXTRACTED_FIELD_NAMES: tuple[str, ...] = (
"基金代码",
"基金名称",
"账户标识",
"投资者名称",
"客户标识",
"申请编号",
"申请日期",
"代销机构",
"申购金额",
"金额单位",
"赎回份额",
"最新净值",
"基金最新总份额",
"申请前持有份额",
"当前最新可用份额",
)
@dataclass(frozen=True)
class RecognitionSourceFile:
filename: str
media_type: str
payload: bytes
file_hash: str
original_file_path: str = ""
@dataclass(frozen=True)
class OcrRecognitionResult:
status: RecognitionStatus
ocr_text: str
tables: list[object]
page_evidence: dict[str, object]
raw_summary: dict[str, object]
error_message: str | None = None
@dataclass(frozen=True)
class StructuredRecognitionResult:
document_type: DocumentType
extracted_fields: dict[str, object]
field_confidence: dict[str, Decimal]
missing_fields: tuple[str, ...]
low_confidence_fields: tuple[str, ...]
page_evidence: dict[str, object]
ocr_text: str
ocr_status: RecognitionStatus
llm_status: RecognitionStatus
error_message: str | None = None
class OffsiteDocumentRecognitionAdapter:
"""先 OCR,再用 DeepSeek 或本地 Mock 生成统一字段。"""
def __init__(
self,
settings: Settings,
*,
client: httpx.AsyncClient | None = None,
aliyun_client: object | None = None,
) -> None:
self.settings = settings
self.client = client
self.aliyun_client = aliyun_client
self._owns_client = client is None
def health_check(self) -> dict[str, object]:
ocr_missing = self._ocr_missing_config() if self.settings.offsite_ocr_enabled else []
if (
self.settings.offsite_ocr_enabled
and self._uses_official_docmind()
and self.aliyun_client is None
and not self._docmind_sdk_available()
):
ocr_missing.append("ALIBABACLOUD_DOCMIND_SDK")
llm_missing = (
self._deepseek_missing_config() if self.settings.offsite_deepseek_enabled else []
)
return {
"ocr": (
{"status": "misconfigured", "missing": ocr_missing}
if ocr_missing
else {"status": "ok" if self.settings.offsite_ocr_enabled else "disabled"}
),
"deepseek": (
{"status": "misconfigured", "missing": llm_missing}
if llm_missing
else {
"status": "ok" if self.settings.offsite_deepseek_enabled else "disabled",
"model": self.settings.offsite_deepseek_model,
}
),
}
async def recognize(self, source: RecognitionSourceFile) -> StructuredRecognitionResult:
ocr = await self._recognize_ocr(source)
ocr = self._with_docx_text_fallback(source, ocr)
return await self._extract_fields(source, ocr)
async def close(self) -> None:
"""收工时关闭自己创建的 HTTP 客户端,由调用方一次性调用。
为什么不能放在单次 recognize() 结尾:同一个 Worker 进程会连续处理多封邮件,
客户端在第一次识别结束时被关闭后,`_client()` 仍会把这个已关闭的对象交回调用方,
于是从第二封邮件起每次都抛 "Cannot send a request, as the client has been closed.",
重试到阈值后把收件游标拖成 blocked,整个收件箱停止收信。
关闭动作集中在这里,保证"建一次、用一路、收工关一次"。
"""
if not self._owns_client or self.client is None:
return
client, self.client = self.client, None
await client.aclose()
async def _recognize_ocr(self, source: RecognitionSourceFile) -> OcrRecognitionResult:
if not self.settings.offsite_ocr_enabled:
text = _mock_text(source)
return OcrRecognitionResult(
status="mock",
ocr_text=text,
tables=[],
page_evidence={},
raw_summary={"provider": "mock", "filename": source.filename},
)
missing = self._ocr_missing_config()
if missing:
return OcrRecognitionResult(
status="misconfigured",
ocr_text="",
tables=[],
page_evidence={},
raw_summary={},
error_message=f"OCR配置缺失:{', '.join(missing)}",
)
if self._uses_official_docmind():
return await self._recognize_official_docmind(source)
client = self._client()
payload = {
"filename": source.filename,
"media_type": source.media_type,
"file_hash": source.file_hash,
"content_base64": base64.b64encode(source.payload).decode("ascii"),
}
try:
response = await client.post(
self.settings.offsite_aliyun_ocr_endpoint,
headers={
"X-Acs-AccessKey-Id": self.settings.offsite_aliyun_access_key_id,
"X-Acs-Signed-Gateway": "offsite-fund",
},
json=payload,
timeout=httpx.Timeout(self.settings.offsite_ocr_timeout_seconds),
)
response.raise_for_status()
body = _json_mapping(response)
data = _mapping_value(body, "data")
text = _text_from_mapping(data) or _text_from_mapping(body)
tables = _list_value(data, "tables") or _list_value(body, "tables")
page_evidence = _dict_value(data, "page_evidence") or _dict_value(body, "page_evidence")
return OcrRecognitionResult(
status="success",
ocr_text=text,
tables=tables,
page_evidence=page_evidence,
raw_summary={
"provider": "aliyun_document_ai",
"response_keys": sorted(str(key) for key in body.keys()),
},
)
except (httpx.HTTPError, ValueError) as exc:
return OcrRecognitionResult(
status="error",
ocr_text="",
tables=[],
page_evidence={},
raw_summary={"provider": "aliyun_document_ai"},
error_message=type(exc).__name__,
)
async def _recognize_official_docmind(
self, source: RecognitionSourceFile
) -> OcrRecognitionResult:
deadline = time.monotonic() + self.settings.offsite_ocr_timeout_seconds
file_stream: Any | None = None
try:
client = self._aliyun_client()
sdk_client = cast(Any, client)
if source.original_file_path:
file_stream = open(source.original_file_path, "rb")
else:
file_stream = BytesIO(source.payload)
submit_request = self._aliyun_request(
"SubmitDocParserJobAdvanceRequest",
file_url_object=file_stream,
file_name=Path(source.filename).name,
)
submit_response = await self._aliyun_call(
sdk_client.submit_doc_parser_job_advance,
submit_request,
deadline,
self._aliyun_runtime_options(),
)
job_id = self._aliyun_value(submit_response, "body", "data", "id")
if not isinstance(job_id, str) or not job_id:
raise ValueError("阿里云文档解析未返回任务 ID")
await self._wait_for_docmind(client, job_id, deadline)
result_response = await self._aliyun_call(
sdk_client.get_doc_parser_result,
self._aliyun_request(
"GetDocParserResultRequest",
id=job_id,
layout_num=0,
layout_step_size=3000,
),
deadline,
)
text, tables, evidence = self._docmind_result(result_response)
return OcrRecognitionResult(
status="success",
ocr_text=text,
tables=tables,
page_evidence=evidence,
raw_summary={
"provider": "aliyun_docmind",
"job_id": job_id,
},
)
except (OSError, ImportError, TimeoutError, ValueError, TypeError, AttributeError) as exc:
return OcrRecognitionResult(
status="error",
ocr_text="",
tables=[],
page_evidence={},
raw_summary={"provider": "aliyun_docmind"},
error_message=type(exc).__name__,
)
finally:
if file_stream is not None:
file_stream.close()
async def _wait_for_docmind(
self, client: object, job_id: str, deadline: float
) -> None:
sdk_client = cast(Any, client)
while True:
response = await self._aliyun_call(
sdk_client.query_doc_parser_status,
self._aliyun_request("QueryDocParserStatusRequest", id=job_id),
deadline,
)
status = str(self._aliyun_value(response, "body", "data", "status") or "")
if status.lower() == "success":
return
if status.lower() == "fail":
message = self._aliyun_value(response, "body", "message")
raise ValueError(f"阿里云文档解析失败:{message or '未知错误'}")
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("阿里云文档解析轮询超时")
await asyncio.sleep(min(self.settings.offsite_aliyun_poll_interval_seconds, remaining))
async def _aliyun_call(
self, method: object, request: object, deadline: float, *extra: object
) -> object:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("阿里云文档解析调用超时")
callable_method = cast(Any, method)
return await asyncio.wait_for(
asyncio.to_thread(callable_method, request, *extra),
timeout=remaining,
)
def _aliyun_runtime_options(self) -> object:
try:
tea_util_models = import_module("alibabacloud_tea_util.models")
except ImportError as exc:
raise ImportError("缺少阿里云 Tea 运行时依赖,请安装 alibabacloud_tea_util") from exc
runtime_options = cast(Any, tea_util_models.RuntimeOptions)
timeout_milliseconds = max(
1, int(self.settings.offsite_ocr_timeout_seconds * 1000)
)
return runtime_options(
read_timeout=timeout_milliseconds,
connect_timeout=timeout_milliseconds,
)
def _aliyun_client(self) -> object:
if self.aliyun_client is not None:
return self.aliyun_client
try:
docmind_module = import_module("alibabacloud_docmind_api20220711.client")
openapi_models = import_module("alibabacloud_tea_openapi.models")
except ImportError as exc:
raise ImportError(
"缺少阿里云文档智能 SDK,请安装 alibabacloud_docmind_api20220711"
) from exc
docmind_client = cast(Any, docmind_module.Client)
open_api_models = cast(Any, openapi_models)
endpoint = self.settings.offsite_aliyun_ocr_endpoint.removeprefix("https://")
endpoint = endpoint.removeprefix("http://").rstrip("/")
config = open_api_models.Config(
access_key_id=self.settings.offsite_aliyun_access_key_id,
access_key_secret=self.settings.offsite_aliyun_access_key_secret,
)
config.endpoint = endpoint
config.region_id = self.settings.offsite_aliyun_region_id
self.aliyun_client = docmind_client(config)
return self.aliyun_client
def _aliyun_request(self, class_name: str, **values: object) -> object:
filtered_values = {key: value for key, value in values.items() if value is not None}
if self.aliyun_client is not None and not self._docmind_sdk_available():
return _LocalDocmindRequest(class_name=class_name, values=filtered_values)
try:
models_module = import_module("alibabacloud_docmind_api20220711.models")
except ImportError as exc:
raise ImportError(
"缺少阿里云文档智能 SDK,请安装 alibabacloud_docmind_api20220711"
) from exc
models = cast(Any, models_module)
request_class = getattr(models, class_name, None)
if request_class is None:
raise ValueError(f"阿里云文档智能 SDK 不支持请求模型:{class_name}")
return request_class(**filtered_values)
@staticmethod
def _docmind_sdk_available() -> bool:
try:
return all(
util.find_spec(module_name) is not None
for module_name in (
"alibabacloud_docmind_api20220711",
"alibabacloud_tea_openapi",
)
)
except (ImportError, ModuleNotFoundError):
return False
@staticmethod
def _aliyun_value(value: object, *path: str) -> object:
current = value
for key in path:
if current is None:
return None
if hasattr(current, "to_map"):
current = current.to_map()
if isinstance(current, Mapping):
current = OffsiteDocumentRecognitionAdapter._mapping_value(current, key)
else:
current = getattr(current, key, None)
return current
@staticmethod
def _mapping_value(value: Mapping[object, object], key: str) -> object:
camel = re.sub(r"_([a-z])", lambda match: match.group(1).upper(), key)
for candidate in (key, camel, key[:1].upper() + key[1:], camel[:1].upper() + camel[1:]):
if candidate in value:
return value[candidate]
return None
def _docmind_result(
self, response: object
) -> tuple[str, list[object], dict[str, object]]:
raw_layouts = self._aliyun_value(response, "body", "data", "layouts")
layouts = raw_layouts if isinstance(raw_layouts, list) else []
text_parts: list[str] = []
tables: list[object] = []
evidence: dict[str, object] = {}
for layout_index, layout in enumerate(layouts, start=1):
markdown = self._aliyun_value(layout, "markdown_content")
if isinstance(markdown, str) and markdown:
text_parts.append(markdown)
blocks = self._aliyun_value(layout, "blocks")
if isinstance(blocks, list):
for block_index, block in enumerate(blocks, start=1):
block_text = self._aliyun_value(block, "text")
if isinstance(block_text, str) and block_text:
text_parts.append(block_text)
if self._aliyun_value(block, "type") in {"table", "form"}:
tables.append(self._plain_value(block))
evidence[f"layout_{layout_index}_block_{block_index}"] = (
self._plain_value(block)
)
return "\n".join(text_parts), tables, evidence
@staticmethod
def _plain_value(value: object) -> object:
if hasattr(value, "to_map"):
return OffsiteDocumentRecognitionAdapter._plain_value(value.to_map())
if isinstance(value, Mapping):
return {
str(key): OffsiteDocumentRecognitionAdapter._plain_value(item)
for key, item in value.items()
}
if isinstance(value, list):
return [OffsiteDocumentRecognitionAdapter._plain_value(item) for item in value]
return value
def _uses_official_docmind(self) -> bool:
endpoint = self.settings.offsite_aliyun_ocr_endpoint.lower()
return "docmind-api." in endpoint
@staticmethod
def _with_docx_text_fallback(
source: RecognitionSourceFile, result: OcrRecognitionResult
) -> OcrRecognitionResult:
if not _is_docx_source(source):
return result
fallback = _extract_docx_text(source.payload)
if not fallback or fallback in result.ocr_text:
return result
summary = {**result.raw_summary, "docx_text_fallback": True}
return OcrRecognitionResult(
status=result.status,
ocr_text=f"{result.ocr_text}\n{fallback}".strip(),
tables=result.tables,
page_evidence=result.page_evidence,
raw_summary=summary,
error_message=result.error_message,
)
async def _extract_fields(
self, source: RecognitionSourceFile, ocr: OcrRecognitionResult
) -> StructuredRecognitionResult:
if ocr.status in {"error", "misconfigured"} or (
ocr.status == "success"
and not ocr.ocr_text.strip()
and not ocr.tables
):
return _recognition_error_result(ocr)
if not self.settings.offsite_deepseek_enabled:
return _mock_structured_result(source, ocr, "mock")
missing = self._deepseek_missing_config()
if missing:
result = _mock_structured_result(source, ocr, "misconfigured")
return _with_error(result, f"DeepSeek配置缺失:{', '.join(missing)}")
try:
body = await self._call_deepseek(source, ocr)
document_type = _document_type(body.get("document_type"))
fields = _recognized_fields(body)
confidence = _confidence_map(_mapping_dict(body, "field_confidence"))
page_evidence = _mapping_dict(body, "page_evidence") or ocr.page_evidence
missing_fields = tuple(
str(item) for item in _mapping_list(body, "missing_fields")
)
low_fields = tuple(
str(item) for item in _mapping_list(body, "low_confidence_fields")
)
if document_type in ("subscription", "redemption"):
missing_fields = _relevant_missing_fields(
document_type, fields, missing_fields
)
if not low_fields:
low_fields = _low_confidence_fields(confidence)
return StructuredRecognitionResult(
document_type=document_type,
extracted_fields=fields,
field_confidence=confidence,
missing_fields=missing_fields,
low_confidence_fields=low_fields,
page_evidence=page_evidence,
ocr_text=ocr.ocr_text,
ocr_status=ocr.status,
llm_status="success",
error_message=ocr.error_message,
)
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
result = _mock_structured_result(source, ocr, "error")
return _with_error(result, type(exc).__name__)
async def _call_deepseek(
self, source: RecognitionSourceFile, ocr: OcrRecognitionResult
) -> Mapping[str, object]:
client = self._client()
response = await client.post(
self.settings.offsite_deepseek_base_url.rstrip("/") + "/chat/completions",
headers={
"Authorization": f"Bearer {self.settings.offsite_deepseek_api_key}",
"Content-Type": "application/json",
},
json={
"model": self.settings.offsite_deepseek_model,
"messages": [
{
"role": "system",
"content": (
"你只输出一个JSON对象,不要输出Markdown、解释或代码围栏。"
"JSON必须严格包含document_type、extracted_fields、"
"field_confidence、missing_fields、low_confidence_fields、"
"page_evidence六个顶层字段。"
"extracted_fields必须是对象,字段名只能使用下面列出的中文字段;"
"无法从原文确认的字段填null,不得猜测或编造。"
"文档中的产品代码、产品编号、基金产品代码均映射为基金代码;"
"基金代码不要求固定六位,必须按原文保留。"
"document_type只能是summary、subscription、redemption、other。"
"模板:"
'{"document_type":"other","extracted_fields":'
'{"基金代码":null,"基金名称":null,"账户标识":null,'
'"投资者名称":null,"客户标识":null,"申请编号":null,'
'"申请日期":null,"代销机构":null,"申购金额":null,'
'"金额单位":null,"赎回份额":null,"最新净值":null,'
'"基金最新总份额":null,"申请前持有份额":null,'
'"当前最新可用份额":null},'
'"field_confidence":{},"missing_fields":[],'
'"low_confidence_fields":[],"page_evidence":{}}'
),
},
{
"role": "user",
"content": json.dumps(
{
"filename": source.filename,
"media_type": source.media_type,
"ocr_text": ocr.ocr_text[:12000],
"tables": ocr.tables,
"page_evidence": ocr.page_evidence,
},
ensure_ascii=False,
),
},
],
"temperature": 0,
"response_format": {"type": "json_object"},
},
timeout=httpx.Timeout(self.settings.offsite_deepseek_timeout_seconds),
)
response.raise_for_status()
content = _deepseek_content(_json_mapping(response))
parsed = json.loads(content)
if not isinstance(parsed, dict):
raise ValueError("DeepSeek响应不是JSON对象")
return cast(Mapping[str, object], parsed)
def _client(self) -> httpx.AsyncClient:
if self.client is None:
self.client = httpx.AsyncClient()
return self.client
def _ocr_missing_config(self) -> list[str]:
missing = []
if not self.settings.offsite_aliyun_ocr_endpoint:
missing.append("OFFSITE_ALIYUN_OCR_ENDPOINT")
if not self.settings.offsite_aliyun_access_key_id:
missing.append("OFFSITE_ALIYUN_ACCESS_KEY_ID")
if not self.settings.offsite_aliyun_access_key_secret:
missing.append("OFFSITE_ALIYUN_ACCESS_KEY_SECRET")
return missing
def _deepseek_missing_config(self) -> list[str]:
missing = []
if not self.settings.offsite_deepseek_base_url:
missing.append("OFFSITE_DEEPSEEK_BASE_URL")
if not self.settings.offsite_deepseek_api_key:
missing.append("OFFSITE_DEEPSEEK_API_KEY")
if not self.settings.offsite_deepseek_model:
missing.append("OFFSITE_DEEPSEEK_MODEL")
return missing
def _mock_text(source: RecognitionSourceFile) -> str:
if _is_docx_source(source):
return f"文件名:{source.filename}"
text = source.payload.decode("utf-8", errors="ignore").strip()
return text or f"文件名:{source.filename}"
def _is_docx_source(source: RecognitionSourceFile) -> bool:
return (
source.filename.lower().endswith(".docx")
or source.media_type
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
)
@dataclass(frozen=True)
class _LocalDocmindRequest:
"""供注入式假客户端使用的轻量请求对象。"""
class_name: str
values: dict[str, object]
def _mock_structured_result(
source: RecognitionSourceFile, ocr: OcrRecognitionResult, llm_status: RecognitionStatus
) -> StructuredRecognitionResult:
document_type = _classify(source.filename + "\n" + ocr.ocr_text)
fields = _extract_simple_fields(ocr.ocr_text)
confidence = {key: Decimal("1") for key in fields}
missing_fields: tuple[str, ...] = ()
if document_type in ("subscription", "redemption"):
missing_fields = _missing_fields(document_type, fields)
return StructuredRecognitionResult(
document_type=document_type,
extracted_fields=fields,
field_confidence=confidence,
missing_fields=missing_fields,
low_confidence_fields=(),
page_evidence=ocr.page_evidence,
ocr_text=ocr.ocr_text,
ocr_status=ocr.status,
llm_status=llm_status,
error_message=ocr.error_message,
)
def _recognition_error_result(ocr: OcrRecognitionResult) -> StructuredRecognitionResult:
return StructuredRecognitionResult(
document_type="other",
extracted_fields={},
field_confidence={},
missing_fields=(),
low_confidence_fields=(),
page_evidence=ocr.page_evidence,
ocr_text=ocr.ocr_text,
ocr_status="error",
llm_status="error",
error_message=ocr.error_message or "OCR未返回可用内容",
)
def _with_error(result: StructuredRecognitionResult, message: str) -> StructuredRecognitionResult:
return StructuredRecognitionResult(
document_type=result.document_type,
extracted_fields=result.extracted_fields,
field_confidence=result.field_confidence,
missing_fields=result.missing_fields,
low_confidence_fields=result.low_confidence_fields,
page_evidence=result.page_evidence,
ocr_text=result.ocr_text,
ocr_status=result.ocr_status,
llm_status=result.llm_status,
error_message=message,
)
def _classify(text: str) -> DocumentType:
if "汇总" in text:
return "summary"
if "赎回" in text:
return "redemption"
if "申购" in text:
return "subscription"
return "other"
def _extract_simple_fields(text: str) -> dict[str, object]:
fields: dict[str, object] = {}
for name in (
"基金代码",
"基金名称",
"账户标识",
"投资者名称",
"客户标识",
"申请编号",
"申请日期",
"代销机构",
"申购金额",
"金额单位",
"赎回份额",
):
labels = ("基金代码", "产品代码") if name == "基金代码" else (name,)
value = next(
(
found
for label in labels
if (found := _find_label_value(text, label)) is not None
),
None,
)
if value:
fields[name] = value
return fields
def _find_label_value(text: str, label: str) -> str | None:
pattern = rf"{re.escape(label)}\s*[::]?\s*([^\s,,;;]+)"
matched = re.search(pattern, text)
if not matched:
return None
return matched.group(1).strip()
def _extract_docx_text(payload: bytes) -> str:
try:
with zipfile.ZipFile(BytesIO(payload)) as archive:
xml = archive.read("word/document.xml")
root = ElementTree.fromstring(xml)
except (OSError, KeyError, ValueError, zipfile.BadZipFile, ElementTree.ParseError):
return ""
namespace = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
paragraphs = []
for paragraph in root.findall(".//w:p", namespace):
text = "".join(node.text or "" for node in paragraph.findall(".//w:t", namespace))
if text.strip():
paragraphs.append(text.strip())
return "\n".join(paragraphs)
def _missing_fields(document_type: str, fields: Mapping[str, object]) -> tuple[str, ...]:
required = list(REQUIRED_FIELDS.get(document_type, ()))
if not (fields.get("投资者名称") or fields.get("客户标识")):
required.append("投资者名称/客户标识")
return tuple(name for name in required if not str(fields.get(name) or "").strip())
def _relevant_missing_fields(
document_type: str,
fields: Mapping[str, object],
reported: tuple[str, ...],
) -> tuple[str, ...]:
required = set(REQUIRED_FIELDS.get(document_type, ()))
required.add("投资者名称/客户标识")
filtered = tuple(
name for name in reported
if name in required and (
name == "投资者名称/客户标识"
or not str(fields.get(name) or "").strip()
)
)
return tuple(dict.fromkeys((*filtered, *_missing_fields(document_type, fields))))
def _low_confidence_fields(confidence: Mapping[str, Decimal]) -> tuple[str, ...]:
return tuple(name for name, value in confidence.items() if value < Decimal("0.95"))
def _confidence_map(value: Mapping[str, object]) -> dict[str, Decimal]:
result: dict[str, Decimal] = {}
for key, raw in value.items():
try:
result[str(key)] = Decimal(str(raw))
except (InvalidOperation, ValueError):
continue
return result
def _document_type(value: object) -> DocumentType:
if value in ("summary", "subscription", "redemption", "other"):
return cast(DocumentType, value)
return "other"
def _json_mapping(response: httpx.Response) -> Mapping[str, object]:
body: Any = response.json()
if not isinstance(body, dict):
raise ValueError("响应不是JSON对象")
return cast(Mapping[str, object], body)
def _mapping_value(value: Mapping[str, object], key: str) -> Mapping[str, object]:
nested = value.get(key)
if isinstance(nested, dict):
return cast(Mapping[str, object], nested)
return {}
def _dict_value(value: Mapping[str, object], key: str) -> dict[str, object]:
nested = value.get(key)
if isinstance(nested, dict):
return dict(cast(Mapping[str, object], nested))
return {}
def _mapping_dict(value: Mapping[str, object], key: str) -> dict[str, object]:
nested = value.get(key)
if isinstance(nested, dict):
return dict(cast(Mapping[str, object], nested))
return {}
def _mapping_list(value: Mapping[str, object], key: str) -> list[object]:
nested = value.get(key)
return list(nested) if isinstance(nested, list) else []
def _recognized_fields(body: Mapping[str, object]) -> dict[str, object]:
nested = _mapping_dict(body, "extracted_fields")
fields = {
name: value
for name, value in nested.items()
if name in EXTRACTED_FIELD_NAMES and value is not None
}
for name in EXTRACTED_FIELD_NAMES:
value = body.get(name)
if name not in fields and value is not None:
fields[name] = value
return fields
def _list_value(value: Mapping[str, object], key: str) -> list[object]:
nested = value.get(key)
return list(nested) if isinstance(nested, list) else []
def _text_from_mapping(value: Mapping[str, object]) -> str:
for key in ("ocr_text", "text", "content", "markdown"):
candidate = value.get(key)
if isinstance(candidate, str):
return candidate
return ""
def _deepseek_content(body: Mapping[str, object]) -> str:
choices = body.get("choices")
if not isinstance(choices, list) or not choices:
raise ValueError("DeepSeek响应缺少choices")
first = choices[0]
if not isinstance(first, dict):
raise ValueError("DeepSeek响应格式错误")
message = first.get("message")
if not isinstance(message, dict):
raise ValueError("DeepSeek响应缺少message")
content = message.get("content")
if not isinstance(content, str) or not content.strip():
raise ValueError("DeepSeek响应缺少content")
return content