2026-09-10 09:23:22 +08:00
|
|
|
|
"""场外基金附件 OCR 与字段识别适配层。"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-09-11 16:57:47 +08:00
|
|
|
|
import asyncio
|
2026-09-10 09:23:22 +08:00
|
|
|
|
import base64
|
|
|
|
|
|
import json
|
|
|
|
|
|
import re
|
2026-09-11 16:57:47 +08:00
|
|
|
|
import time
|
|
|
|
|
|
import zipfile
|
2026-09-10 09:23:22 +08:00
|
|
|
|
from collections.abc import Mapping
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
from decimal import Decimal, InvalidOperation
|
2026-09-11 16:57:47 +08:00
|
|
|
|
from importlib import import_module, util
|
|
|
|
|
|
from io import BytesIO
|
|
|
|
|
|
from pathlib import Path
|
2026-09-10 09:23:22 +08:00
|
|
|
|
from typing import Any, Literal, cast
|
2026-09-11 16:57:47 +08:00
|
|
|
|
from xml.etree import ElementTree
|
2026-09-10 09:23:22 +08:00
|
|
|
|
|
|
|
|
|
|
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": (
|
|
|
|
|
|
"基金代码",
|
|
|
|
|
|
"基金名称",
|
|
|
|
|
|
"账户标识",
|
|
|
|
|
|
"申请编号",
|
|
|
|
|
|
"申请日期",
|
|
|
|
|
|
"代销机构",
|
|
|
|
|
|
"赎回份额",
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 16:57:47 +08:00
|
|
|
|
EXTRACTED_FIELD_NAMES: tuple[str, ...] = (
|
|
|
|
|
|
"基金代码",
|
|
|
|
|
|
"基金名称",
|
|
|
|
|
|
"账户标识",
|
|
|
|
|
|
"投资者名称",
|
|
|
|
|
|
"客户标识",
|
|
|
|
|
|
"申请编号",
|
|
|
|
|
|
"申请日期",
|
|
|
|
|
|
"代销机构",
|
|
|
|
|
|
"申购金额",
|
|
|
|
|
|
"金额单位",
|
|
|
|
|
|
"赎回份额",
|
|
|
|
|
|
"最新净值",
|
|
|
|
|
|
"基金最新总份额",
|
|
|
|
|
|
"申请前持有份额",
|
|
|
|
|
|
"当前最新可用份额",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-10 09:23:22 +08:00
|
|
|
|
|
|
|
|
|
|
@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 生成统一字段。"""
|
|
|
|
|
|
|
2026-09-11 16:57:47 +08:00
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
settings: Settings,
|
|
|
|
|
|
*,
|
|
|
|
|
|
client: httpx.AsyncClient | None = None,
|
|
|
|
|
|
aliyun_client: object | None = None,
|
|
|
|
|
|
) -> None:
|
2026-09-10 09:23:22 +08:00
|
|
|
|
self.settings = settings
|
|
|
|
|
|
self.client = client
|
2026-09-11 16:57:47 +08:00
|
|
|
|
self.aliyun_client = aliyun_client
|
2026-09-10 09:23:22 +08:00
|
|
|
|
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 []
|
2026-09-11 16:57:47 +08:00
|
|
|
|
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")
|
2026-09-10 09:23:22 +08:00
|
|
|
|
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)
|
2026-09-11 16:57:47 +08:00
|
|
|
|
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()
|
2026-09-10 09:23:22 +08:00
|
|
|
|
|
|
|
|
|
|
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)}",
|
|
|
|
|
|
)
|
2026-09-11 16:57:47 +08:00
|
|
|
|
if self._uses_official_docmind():
|
|
|
|
|
|
return await self._recognize_official_docmind(source)
|
2026-09-10 09:23:22 +08:00
|
|
|
|
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__,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-11 16:57:47 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-10 09:23:22 +08:00
|
|
|
|
async def _extract_fields(
|
|
|
|
|
|
self, source: RecognitionSourceFile, ocr: OcrRecognitionResult
|
|
|
|
|
|
) -> StructuredRecognitionResult:
|
2026-09-11 16:57:47 +08:00
|
|
|
|
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)
|
2026-09-10 09:23:22 +08:00
|
|
|
|
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"))
|
2026-09-11 16:57:47 +08:00
|
|
|
|
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
|
|
|
|
|
|
)
|
2026-09-10 09:23:22 +08:00
|
|
|
|
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": (
|
2026-09-11 16:57:47 +08:00
|
|
|
|
"你只输出一个JSON对象,不要输出Markdown、解释或代码围栏。"
|
|
|
|
|
|
"JSON必须严格包含document_type、extracted_fields、"
|
|
|
|
|
|
"field_confidence、missing_fields、low_confidence_fields、"
|
|
|
|
|
|
"page_evidence六个顶层字段。"
|
|
|
|
|
|
"extracted_fields必须是对象,字段名只能使用下面列出的中文字段;"
|
|
|
|
|
|
"无法从原文确认的字段填null,不得猜测或编造。"
|
|
|
|
|
|
"文档中的产品代码、产品编号、基金产品代码均映射为基金代码;"
|
|
|
|
|
|
"基金代码不要求固定六位,必须按原文保留。"
|
2026-09-10 09:23:22 +08:00
|
|
|
|
"document_type只能是summary、subscription、redemption、other。"
|
2026-09-11 16:57:47 +08:00
|
|
|
|
"模板:"
|
|
|
|
|
|
'{"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":{}}'
|
2026-09-10 09:23:22 +08:00
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"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:
|
2026-09-11 16:57:47 +08:00
|
|
|
|
if _is_docx_source(source):
|
|
|
|
|
|
return f"文件名:{source.filename}"
|
2026-09-10 09:23:22 +08:00
|
|
|
|
text = source.payload.decode("utf-8", errors="ignore").strip()
|
|
|
|
|
|
return text or f"文件名:{source.filename}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 16:57:47 +08:00
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 09:23:22 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 16:57:47 +08:00
|
|
|
|
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未返回可用内容",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 09:23:22 +08:00
|
|
|
|
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 (
|
|
|
|
|
|
"基金代码",
|
|
|
|
|
|
"基金名称",
|
|
|
|
|
|
"账户标识",
|
|
|
|
|
|
"投资者名称",
|
|
|
|
|
|
"客户标识",
|
|
|
|
|
|
"申请编号",
|
|
|
|
|
|
"申请日期",
|
|
|
|
|
|
"代销机构",
|
|
|
|
|
|
"申购金额",
|
|
|
|
|
|
"金额单位",
|
|
|
|
|
|
"赎回份额",
|
|
|
|
|
|
):
|
2026-09-11 16:57:47 +08:00
|
|
|
|
labels = ("基金代码", "产品代码") if name == "基金代码" else (name,)
|
|
|
|
|
|
value = next(
|
|
|
|
|
|
(
|
|
|
|
|
|
found
|
|
|
|
|
|
for label in labels
|
|
|
|
|
|
if (found := _find_label_value(text, label)) is not None
|
|
|
|
|
|
),
|
|
|
|
|
|
None,
|
|
|
|
|
|
)
|
2026-09-10 09:23:22 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 16:57:47 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 09:23:22 +08:00
|
|
|
|
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())
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 16:57:47 +08:00
|
|
|
|
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))))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 09:23:22 +08:00
|
|
|
|
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"):
|
2026-09-11 20:15:17 +08:00
|
|
|
|
# 不需要 cast:上面的 `in (...)` 已经把类型收窄到那个字面量联合了。
|
|
|
|
|
|
return value
|
2026-09-10 09:23:22 +08:00
|
|
|
|
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 {}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 16:57:47 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 09:23:22 +08:00
|
|
|
|
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
|