Files
group_fqcd_jr/app/service/offsite_document_recognition_adapter.py
T

867 lines
33 KiB
Python
Raw Normal View History

"""场外基金附件 OCR 与字段识别适配层。"""
from __future__ import annotations
2026-09-11 16:57:47 +08:00
import asyncio
import base64
import json
import re
2026-09-11 16:57:47 +08:00
import time
import zipfile
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
from typing import Any, Literal, cast
2026-09-11 16:57:47 +08:00
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": (
"基金代码",
"基金名称",
"账户标识",
"申请编号",
"申请日期",
"代销机构",
"赎回份额",
),
}
2026-09-11 16:57:47 +08:00
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 生成统一字段。"""
2026-09-11 16:57:47 +08:00
def __init__(
self,
settings: Settings,
*,
client: httpx.AsyncClient | None = None,
aliyun_client: object | None = None,
) -> None:
self.settings = settings
self.client = client
2026-09-11 16:57:47 +08:00
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 []
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")
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()
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)
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,
)
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)
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
)
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,不得猜测或编造。"
"文档中的产品代码、产品编号、基金产品代码均映射为基金代码;"
"基金代码不要求固定六位,必须按原文保留。"
"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":{}}'
),
},
{
"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}"
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]
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未返回可用内容",
)
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,
)
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)
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))))
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"):
# 不需要 cast:上面的 `in (...)` 已经把类型收窄到那个字面量联合了。
return 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 {}
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
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