含易制毒计划安排后进入已安排待确认,确认通过后再占合同并生成链接/地磅;新增购买证/运输证主数据、产品行关联、余量校验与 OCR 配置,并停止跟踪 api_key/app.pid。 Co-authored-by: Cursor <cursoragent@cursor.com>
301 lines
12 KiB
Python
301 lines
12 KiB
Python
"""DeepSeek 视觉识别:易制毒购买证 / 运输证。"""
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import logging
|
||
import mimetypes
|
||
import re
|
||
from typing import Any, Literal, Optional
|
||
|
||
import httpx
|
||
from fastapi import HTTPException
|
||
|
||
from app.core.config import settings
|
||
from app.services.pl_ocr_service import compress_image_bytes
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
CertKind = Literal["purchase", "transport"]
|
||
|
||
PURCHASE_PROMPT = """你是证件信息提取助手。请识别图片中的「第二类、第三类易制毒化学品购买备案证明」文字,严格输出一个 JSON 对象(不要 Markdown,不要解释)。
|
||
须覆盖购买证管理表单全部字段(识别不到填 null):
|
||
{
|
||
"certNo": "编号",
|
||
"issueDate": "核发日期,格式 YYYY-MM-DD",
|
||
"buyerName": "【购买单位或个人】名称或姓名",
|
||
"buyerLegal": "【购买单位或个人】法定代表人",
|
||
"buyerAddr": "【购买单位或个人】住所/地址",
|
||
"buyerTel": "【购买单位或个人】电话",
|
||
"sellerName": "【销售单位】名称",
|
||
"sellerLegal": "【销售单位】法定代表人",
|
||
"sellerAddr": "【销售单位】住所",
|
||
"sellerTel": "【销售单位】电话",
|
||
"productName": "购买物品",
|
||
"usageDesc": "用途",
|
||
"totalQty": 数量数字(仅阿拉伯数字,不要中文大写),
|
||
"unit": "数量单位,如吨",
|
||
"validTimes": "有效次数原文,如多次有效",
|
||
"validFrom": "有效期限开始日 YYYY-MM-DD",
|
||
"validTo": "有效期限结束日 YYYY-MM-DD",
|
||
"policeOrg": "公安机关(核发机关全称)",
|
||
"handlerName": "经办人",
|
||
"policeTel": "公安机关联系电话(若无则 null)"
|
||
}
|
||
"""
|
||
|
||
TRANSPORT_PROMPT = """你是证件信息提取助手。请识别图片中的「第三类易制毒化学品运输备案证明」文字,严格输出一个 JSON 对象(不要 Markdown,不要解释)。
|
||
须覆盖运输证管理表单全部字段(识别不到填 null):
|
||
{
|
||
"certNo": "编号",
|
||
"issueDate": "核发日期,格式 YYYY-MM-DD",
|
||
"ownerName": "【货主】名称或姓名",
|
||
"ownerLegal": "【货主】法定代表人",
|
||
"ownerAddr": "【货主】住所或住址",
|
||
"ownerTel": "【货主】电话",
|
||
"productName": "【运输物品】品名",
|
||
"packSpec": "【运输物品】包装",
|
||
"totalQty": 数量数字(仅阿拉伯数字),
|
||
"unit": "数量单位,如吨",
|
||
"permitCertNos": "【运输物品】购买许可/备案/出口许可证号,多个用英文逗号分隔",
|
||
"carrierName": "【运输单位】名称",
|
||
"transportMode": "【运输单位】运输方式",
|
||
"carrierContact": "【运输单位】住所/电话(可合并为一串,如 地址/电话)",
|
||
"plateNos": "【运输单位】车号(次),多个用顿号或逗号分隔",
|
||
"routeDesc": "【运输单位】运输路线",
|
||
"consigneeName": "【收货单位】名称/姓名",
|
||
"validTimes": "有效次数原文",
|
||
"validFrom": "有效期限开始日 YYYY-MM-DD",
|
||
"validTo": "有效期限结束日 YYYY-MM-DD",
|
||
"applicantUnit": "【申请单位】名称/姓名",
|
||
"policeOrg": "公安机关(核发机关全称)",
|
||
"handlerName": "经办人",
|
||
"policeTel": "公安机关联系电话"
|
||
}
|
||
"""
|
||
|
||
|
||
def _guess_mime(url: str, raw: bytes) -> str:
|
||
lower = (url or "").lower()
|
||
if lower.endswith(".png") or raw[:8] == b"\x89PNG\r\n\x1a\n":
|
||
return "image/png"
|
||
if lower.endswith(".webp"):
|
||
return "image/webp"
|
||
if lower.endswith(".gif"):
|
||
return "image/gif"
|
||
guessed, _ = mimetypes.guess_type(url or "")
|
||
if guessed and guessed.startswith("image/"):
|
||
return guessed
|
||
return "image/jpeg"
|
||
|
||
|
||
def _normalize_date(value: Any) -> Optional[str]:
|
||
if value is None:
|
||
return None
|
||
text = str(value).strip()
|
||
if not text or text.lower() == "null":
|
||
return None
|
||
m = re.search(r"(\d{4})\s*[年./-]\s*(\d{1,2})\s*[月./-]\s*(\d{1,2})", text)
|
||
if m:
|
||
return f"{int(m.group(1)):04d}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
|
||
m2 = re.search(r"^(\d{4})-(\d{2})-(\d{2})$", text)
|
||
if m2:
|
||
return text
|
||
return None
|
||
|
||
|
||
def _normalize_qty(value: Any) -> Optional[float]:
|
||
if value is None or value == "":
|
||
return None
|
||
if isinstance(value, (int, float)):
|
||
return float(value)
|
||
text = str(value).strip().replace(",", "")
|
||
m = re.search(r"(\d+(?:\.\d+)?)", text)
|
||
if not m:
|
||
return None
|
||
try:
|
||
return float(m.group(1))
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _clean_str(value: Any) -> Optional[str]:
|
||
if value is None:
|
||
return None
|
||
text = str(value).strip()
|
||
if not text or text.lower() == "null":
|
||
return None
|
||
return text
|
||
|
||
|
||
def _extract_json_object(content: str) -> dict[str, Any]:
|
||
text = (content or "").strip()
|
||
if not text:
|
||
raise HTTPException(status_code=400, detail="识别结果为空,请换清晰图片重试")
|
||
fence = re.search(r"```(?:json)?\s*([\s\S]*?)```", text, re.I)
|
||
if fence:
|
||
text = fence.group(1).strip()
|
||
try:
|
||
data = json.loads(text)
|
||
if isinstance(data, dict):
|
||
return data
|
||
except json.JSONDecodeError:
|
||
pass
|
||
start = text.find("{")
|
||
end = text.rfind("}")
|
||
if start >= 0 and end > start:
|
||
try:
|
||
data = json.loads(text[start : end + 1])
|
||
if isinstance(data, dict):
|
||
return data
|
||
except json.JSONDecodeError:
|
||
pass
|
||
raise HTTPException(status_code=400, detail="识别结果解析失败,请换清晰图片重试")
|
||
|
||
|
||
def _normalize_purchase(data: dict[str, Any]) -> dict[str, Any]:
|
||
qty = _normalize_qty(data.get("totalQty"))
|
||
unit = _clean_str(data.get("unit")) or "吨"
|
||
return {
|
||
"certNo": _clean_str(data.get("certNo")),
|
||
"issueDate": _normalize_date(data.get("issueDate")),
|
||
"buyerName": _clean_str(data.get("buyerName")),
|
||
"buyerLegal": _clean_str(data.get("buyerLegal")),
|
||
"buyerAddr": _clean_str(data.get("buyerAddr")),
|
||
"buyerTel": _clean_str(data.get("buyerTel")),
|
||
"sellerName": _clean_str(data.get("sellerName")),
|
||
"sellerLegal": _clean_str(data.get("sellerLegal")),
|
||
"sellerAddr": _clean_str(data.get("sellerAddr")),
|
||
"sellerTel": _clean_str(data.get("sellerTel")),
|
||
"productName": _clean_str(data.get("productName")),
|
||
"usageDesc": _clean_str(data.get("usageDesc")),
|
||
"totalQty": qty,
|
||
"unit": unit,
|
||
"validTimes": _clean_str(data.get("validTimes")),
|
||
"validFrom": _normalize_date(data.get("validFrom")),
|
||
"validTo": _normalize_date(data.get("validTo")),
|
||
"policeOrg": _clean_str(data.get("policeOrg")),
|
||
"handlerName": _clean_str(data.get("handlerName")),
|
||
"policeTel": _clean_str(data.get("policeTel")),
|
||
}
|
||
|
||
|
||
def _normalize_transport(data: dict[str, Any]) -> dict[str, Any]:
|
||
qty = _normalize_qty(data.get("totalQty"))
|
||
unit = _clean_str(data.get("unit")) or "吨"
|
||
permit = _clean_str(data.get("permitCertNos"))
|
||
if permit:
|
||
parts = re.split(r"[,,、;;\s]+", permit)
|
||
permit = ",".join(p for p in parts if p)
|
||
return {
|
||
"certNo": _clean_str(data.get("certNo")),
|
||
"issueDate": _normalize_date(data.get("issueDate")),
|
||
"ownerName": _clean_str(data.get("ownerName")),
|
||
"ownerLegal": _clean_str(data.get("ownerLegal")),
|
||
"ownerAddr": _clean_str(data.get("ownerAddr")),
|
||
"ownerTel": _clean_str(data.get("ownerTel")),
|
||
"productName": _clean_str(data.get("productName")),
|
||
"packSpec": _clean_str(data.get("packSpec")),
|
||
"totalQty": qty,
|
||
"unit": unit,
|
||
"permitCertNos": permit,
|
||
"carrierName": _clean_str(data.get("carrierName")),
|
||
"transportMode": _clean_str(data.get("transportMode")),
|
||
"carrierContact": _clean_str(data.get("carrierContact")),
|
||
"plateNos": _clean_str(data.get("plateNos")),
|
||
"routeDesc": _clean_str(data.get("routeDesc")),
|
||
"consigneeName": _clean_str(data.get("consigneeName")),
|
||
"validTimes": _clean_str(data.get("validTimes")),
|
||
"validFrom": _normalize_date(data.get("validFrom")),
|
||
"validTo": _normalize_date(data.get("validTo")),
|
||
"applicantUnit": _clean_str(data.get("applicantUnit")),
|
||
"policeOrg": _clean_str(data.get("policeOrg")),
|
||
"handlerName": _clean_str(data.get("handlerName")),
|
||
"policeTel": _clean_str(data.get("policeTel")),
|
||
}
|
||
|
||
|
||
def _call_deepseek_vision(*, image_bytes: bytes, mime: str, prompt: str) -> str:
|
||
api_key = (settings.DEEPSEEK_API_KEY or "").strip()
|
||
if not api_key:
|
||
raise HTTPException(status_code=400, detail="DeepSeek 未配置,请联系管理员")
|
||
base = (settings.DEEPSEEK_API_BASE or "https://api.deepseek.com").rstrip("/")
|
||
model = (settings.DEEPSEEK_VISION_MODEL or "deepseek-v4-flash-vision-exp").strip()
|
||
timeout = max(30, int(settings.DEEPSEEK_TIMEOUT_SEC or 120))
|
||
b64 = base64.b64encode(image_bytes).decode("ascii")
|
||
data_url = f"data:{mime};base64,{b64}"
|
||
payload = {
|
||
"model": model,
|
||
"temperature": 0.1,
|
||
"messages": [
|
||
{
|
||
"role": "user",
|
||
"content": [
|
||
{"type": "text", "text": prompt},
|
||
{
|
||
"type": "image_url",
|
||
"image_url": {"url": data_url},
|
||
},
|
||
],
|
||
}
|
||
],
|
||
}
|
||
try:
|
||
with httpx.Client(timeout=timeout) as client:
|
||
resp = client.post(
|
||
f"{base}/chat/completions",
|
||
headers={
|
||
"Authorization": f"Bearer {api_key}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json=payload,
|
||
)
|
||
except httpx.TimeoutException as exc:
|
||
raise HTTPException(status_code=400, detail="识别超时,请稍后重试") from exc
|
||
except Exception as exc:
|
||
logger.exception("DeepSeek 请求失败")
|
||
raise HTTPException(status_code=400, detail="识别服务调用失败") from exc
|
||
|
||
if resp.status_code >= 400:
|
||
detail = resp.text[:300]
|
||
logger.error("DeepSeek HTTP %s: %s", resp.status_code, detail)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"识别失败({resp.status_code}),请稍后重试",
|
||
)
|
||
body = resp.json()
|
||
try:
|
||
return body["choices"][0]["message"]["content"]
|
||
except (KeyError, IndexError, TypeError) as exc:
|
||
logger.error("DeepSeek 响应异常: %s", body)
|
||
raise HTTPException(status_code=400, detail="识别响应异常") from exc
|
||
|
||
|
||
def recognize_hazard_cert(*, kind: CertKind, attachment_url: str) -> dict[str, Any]:
|
||
url = (attachment_url or "").strip().split(",")[0].strip()
|
||
if not url:
|
||
raise HTTPException(status_code=400, detail="请先上传证书图片")
|
||
if url.lower().endswith(".pdf"):
|
||
raise HTTPException(status_code=400, detail="暂不支持 PDF,请上传证件图片(jpg/png)")
|
||
|
||
try:
|
||
from app.services.bx.storage import download_file_bytes
|
||
|
||
raw = download_file_bytes(url)
|
||
except Exception as exc:
|
||
logger.exception("下载证照附件失败: %s", url)
|
||
raise HTTPException(status_code=400, detail="附件读取失败,请重新上传后再试") from exc
|
||
|
||
image_bytes = compress_image_bytes(raw)
|
||
mime = _guess_mime(url, image_bytes)
|
||
prompt = PURCHASE_PROMPT if kind == "purchase" else TRANSPORT_PROMPT
|
||
content = _call_deepseek_vision(image_bytes=image_bytes, mime=mime, prompt=prompt)
|
||
data = _extract_json_object(content)
|
||
if kind == "purchase":
|
||
fields = _normalize_purchase(data)
|
||
else:
|
||
fields = _normalize_transport(data)
|
||
# 完整识别结果以 JSON 字符串返回,供前端回填并落库
|
||
fields["ocrResultJson"] = json.dumps(fields, ensure_ascii=False)
|
||
return fields
|