1260 lines
48 KiB
Python
1260 lines
48 KiB
Python
"""AI 智能服务模块
|
||
|
||
提供两大核心能力:
|
||
1. OCR 图片识别:将图片中的文字提取为结构化数据,支持阿里云 OCR 和 Mock 两种 provider。
|
||
2. 智能填单:通过文本预处理、规则提取、LLM 结构化解析、产品库模糊匹配等多层管线,
|
||
从粘贴文本或图片中自动识别订单信息并填充到表单。
|
||
|
||
架构分层:
|
||
- TextPreprocessor:清洗粘贴文本中的时间戳、表情标记等噪声。
|
||
- RuleExtractor:基于正则的确定性字段提取(手机号、数量、价格、地址)。
|
||
- OrderOCRParser:基于 OCR 行列表做订单版面分析和字段提取。
|
||
- LLMOrderParser:调用通义千问 qwen-plus 做订单文本结构化解析。
|
||
- BaseOCRProvider / AliyunOCRProvider / MockOCRProvider:OCR 提供者抽象与实现。
|
||
- AIService:业务入口,串联上述组件完成图片识别和智能填单流程。
|
||
|
||
被调用方:ai 路由(图片识别、识别结果修正、智能填单接口)。
|
||
"""
|
||
|
||
import json
|
||
import re
|
||
from decimal import Decimal
|
||
from difflib import SequenceMatcher
|
||
from urllib import error, parse, request
|
||
|
||
from sqlalchemy.exc import SQLAlchemyError
|
||
from sqlalchemy.orm import Session
|
||
|
||
from backend.app.core.config import get_settings
|
||
from backend.app.core.error_codes import ErrorCode
|
||
from backend.app.core.exceptions import AppException
|
||
from backend.app.repositories.ai_repository import AIRepository
|
||
from backend.app.services.audit_service import audit_service
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 智能填单:文本预处理
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TextPreprocessor:
|
||
"""清洗粘贴文本中的噪声,保留有效订单信息。
|
||
|
||
处理内容包括:移除时间戳、括号时间标记、表情/媒体标记、用户名前缀等,
|
||
并合并多余空行。无外部依赖,纯文本处理。
|
||
"""
|
||
|
||
TIMESTAMP_RE = re.compile(r'\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{2}(:\d{2})?')
|
||
BRACKET_TIME_RE = re.compile(r'\[\d{1,2}:\d{2}(:\d{2})?\]')
|
||
USERNAME_PREFIX_RE = re.compile(r'^[^:\s]{1,10}[::]\s*', re.MULTILINE)
|
||
NOISE_MARKERS = [
|
||
'[图片]', '[表情]', '[语音]', '[视频]', '[文件]', '[链接]',
|
||
'[红包]', '[转账]', '[位置]', '[名片]', '—— ——',
|
||
]
|
||
|
||
def preprocess(self, text: str) -> str:
|
||
"""清洗文本,移除噪声内容并合并多余空行。
|
||
|
||
参数:
|
||
text: 原始粘贴文本。
|
||
|
||
返回:
|
||
清洗后的纯文本,空输入返回空字符串。
|
||
"""
|
||
if not text or not text.strip():
|
||
return ""
|
||
result = text
|
||
result = self.TIMESTAMP_RE.sub('', result)
|
||
result = self.BRACKET_TIME_RE.sub('', result)
|
||
for marker in self.NOISE_MARKERS:
|
||
result = result.replace(marker, '')
|
||
result = self.USERNAME_PREFIX_RE.sub('', result)
|
||
result = re.sub(r'\n{3,}', '\n\n', result)
|
||
return result.strip()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 智能填单:规则提取层
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class RuleExtractor:
|
||
"""基于规则的字段提取器,处理确定性高的字段。
|
||
|
||
通过正则表达式提取手机号、数量+单位、单价,并基于地址关键词密度识别地址段。
|
||
适用于文本预处理后的清洗文本。
|
||
"""
|
||
|
||
PHONE_RE = re.compile(r'1[3-9]\d{9}')
|
||
QTY_UNIT_RE = re.compile(
|
||
r'(\d+\.?\d*)\s*(吨|件|箱|包|个|米|kg|KG|公斤|斤|卷|组|套|台|条|根|片|块)'
|
||
)
|
||
PRICE_RE = re.compile(r'(?:单价|价格|报价)\s*[::]?\s*(\d+\.?\d*)')
|
||
ADDRESS_KEYWORDS = [
|
||
'省', '市', '区', '县', '镇', '路', '街', '号',
|
||
'楼', '室', '栋', '单元', '村', '大厦', '广场',
|
||
]
|
||
|
||
def extract(self, text: str) -> dict:
|
||
"""从文本中提取确定性字段。
|
||
|
||
参数:
|
||
text: 清洗后的文本。
|
||
|
||
返回:
|
||
字典,包含 customer_mobile、_raw_quantity、_raw_unit、_raw_price、customer_address 等字段。
|
||
"""
|
||
result: dict = {}
|
||
phones = self.PHONE_RE.findall(text)
|
||
if phones:
|
||
result['customer_mobile'] = phones[0]
|
||
qty_match = self.QTY_UNIT_RE.search(text)
|
||
if qty_match:
|
||
result['_raw_quantity'] = float(qty_match.group(1))
|
||
result['_raw_unit'] = qty_match.group(2)
|
||
price_match = self.PRICE_RE.search(text)
|
||
if price_match:
|
||
result['_raw_price'] = float(price_match.group(1))
|
||
addr = self._extract_address(text)
|
||
if addr:
|
||
result['customer_address'] = addr
|
||
return result
|
||
|
||
def _extract_address(self, text: str) -> str | None:
|
||
"""基于地址关键词密度提取地址段。
|
||
|
||
逐行扫描,关键词命中数 >= 2 的连续行视为地址段,取最长段。
|
||
|
||
参数:
|
||
text: 清洗后的文本。
|
||
|
||
返回:
|
||
拼接后的地址字符串,未识别到时返回 None。
|
||
"""
|
||
lines = text.split('\n')
|
||
best: list[str] = []
|
||
current: list[str] = []
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
if not stripped:
|
||
continue
|
||
addr_hits = sum(1 for kw in self.ADDRESS_KEYWORDS if kw in stripped)
|
||
if addr_hits >= 2:
|
||
current.append(stripped)
|
||
else:
|
||
if len(current) > len(best):
|
||
best = list(current)
|
||
current = []
|
||
if len(current) > len(best):
|
||
best = current
|
||
return ''.join(best) if best else None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 智能填单:OCR 版面分析
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class OrderOCRParser:
|
||
"""基于 OCR 行列表做订单版面分析。
|
||
|
||
将 OCR 返回的文本行按区域分类(头部、地址、表格、其他),
|
||
从各区域中提取客户信息和产品明细。
|
||
"""
|
||
|
||
PHONE_RE = re.compile(r'1[3-9]\d{9}')
|
||
ADDRESS_KEYWORDS = {
|
||
'省', '市', '区', '县', '镇', '路', '街', '号',
|
||
'楼', '室', '栋', '单元', '村', '大厦', '广场',
|
||
'弄', '巷', '苑', '园', '城',
|
||
}
|
||
QTY_RE = re.compile(
|
||
r'(\d+\.?\d*)\s*(吨|件|箱|包|个|米|kg|KG|公斤|斤|卷|组|套|台|条|根|片|块)'
|
||
)
|
||
PRICE_RE = re.compile(r'(?:单价|价格|报价|¥|¥)\s*[::]?\s*(\d+\.?\d*)')
|
||
TABLE_HEADER_KEYWORDS = {'产品', '品名', '名称', '规格', '数量', '单价', '金额', '合计'}
|
||
|
||
def parse(self, line_list: list[str], ocr_confidence: float) -> dict:
|
||
"""解析 OCR 行列表,输出结构化的版面分析结果。
|
||
|
||
参数:
|
||
line_list: OCR 返回的文本行列表。
|
||
ocr_confidence: OCR 识别置信度。
|
||
|
||
返回:
|
||
包含 raw_text、ocr_confidence、layout_zones、pre_filled、table_rows 的字典。
|
||
"""
|
||
raw_text = '\n'.join(line_list)
|
||
zones = self._classify_lines(line_list)
|
||
pre_filled = self._extract_from_zones(line_list, zones)
|
||
table_rows = self._parse_table_lines(line_list, zones.get('table_lines', []))
|
||
return {
|
||
"raw_text": raw_text,
|
||
"ocr_confidence": ocr_confidence,
|
||
"layout_zones": zones,
|
||
"pre_filled": pre_filled,
|
||
"table_rows": table_rows,
|
||
}
|
||
|
||
def _classify_lines(self, lines: list[str]) -> dict:
|
||
"""将 OCR 文本行按区域类型分类。
|
||
|
||
分类逻辑:前 3 行为头部、含地址关键词的连续行归为地址区、
|
||
遇到表格表头后连续的产品行归为表格区,其余为其他。
|
||
|
||
参数:
|
||
lines: OCR 文本行列表。
|
||
|
||
返回:
|
||
包含 header_lines、address_lines、table_lines、other_lines 的分类索引字典。
|
||
"""
|
||
zones: dict[str, list[int]] = {
|
||
"header_lines": [], "address_lines": [],
|
||
"table_lines": [], "other_lines": [],
|
||
}
|
||
table_started = False
|
||
for i, line in enumerate(lines):
|
||
stripped = line.strip()
|
||
if not stripped:
|
||
continue
|
||
if any(kw in stripped for kw in self.TABLE_HEADER_KEYWORDS):
|
||
table_started = True
|
||
zones["table_lines"].append(i)
|
||
continue
|
||
if table_started:
|
||
if self.QTY_RE.search(stripped) or self.PRICE_RE.search(stripped):
|
||
zones["table_lines"].append(i)
|
||
continue
|
||
table_started = False
|
||
addr_count = sum(1 for kw in self.ADDRESS_KEYWORDS if kw in stripped)
|
||
if addr_count >= 2:
|
||
zones["address_lines"].append(i)
|
||
continue
|
||
if i < 3:
|
||
zones["header_lines"].append(i)
|
||
else:
|
||
zones["other_lines"].append(i)
|
||
return zones
|
||
|
||
def _extract_from_zones(self, lines: list[str], zones: dict) -> dict:
|
||
"""从已分类的区域中提取客户基本信息(姓名、手机、地址)。
|
||
|
||
参数:
|
||
lines: OCR 文本行列表。
|
||
zones: 区域分类结果字典。
|
||
|
||
返回:
|
||
包含 customer_mobile、customer_name、customer_address 的预填字段字典。
|
||
"""
|
||
result: dict = {}
|
||
for i in zones.get("header_lines", []):
|
||
text = lines[i].strip()
|
||
if not text:
|
||
continue
|
||
phone_match = self.PHONE_RE.search(text)
|
||
if phone_match:
|
||
result["customer_mobile"] = phone_match.group(0)
|
||
name_part = self.PHONE_RE.sub('', text).strip()
|
||
if name_part and len(name_part) <= 20 and not any(
|
||
kw in name_part for kw in self.ADDRESS_KEYWORDS
|
||
):
|
||
result.setdefault("customer_name", name_part)
|
||
addr_parts = [lines[i].strip() for i in zones.get("address_lines", [])]
|
||
if addr_parts:
|
||
result["customer_address"] = ''.join(addr_parts)
|
||
return result
|
||
|
||
def _parse_table_lines(self, lines: list[str], table_indices: list[int]) -> list[dict]:
|
||
"""解析表格区域中的产品行,提取数量、单价和产品名称。
|
||
|
||
参数:
|
||
lines: OCR 文本行列表。
|
||
table_indices: 表格区域的行索引列表。
|
||
|
||
返回:
|
||
产品行列表,每个元素包含 raw(原始行)和 fields(提取的字段)字典。
|
||
"""
|
||
rows: list[dict] = []
|
||
for i in table_indices:
|
||
line = lines[i].strip()
|
||
if not line:
|
||
continue
|
||
if any(kw in line for kw in self.TABLE_HEADER_KEYWORDS) and not self.QTY_RE.search(line):
|
||
continue
|
||
row: dict = {"raw": line, "fields": {}}
|
||
qty_match = self.QTY_RE.search(line)
|
||
if qty_match:
|
||
row["fields"]["quantity"] = float(qty_match.group(1))
|
||
row["fields"]["unit"] = qty_match.group(2)
|
||
price_match = self.PRICE_RE.search(line)
|
||
if price_match:
|
||
row["fields"]["sale_price"] = float(price_match.group(1))
|
||
name_part = self.QTY_RE.sub('', line)
|
||
name_part = self.PRICE_RE.sub('', name_part)
|
||
name_part = re.sub(r'\d+\.?\d*', '', name_part).strip()
|
||
if name_part:
|
||
row["fields"]["product_name"] = name_part
|
||
if row["fields"]:
|
||
rows.append(row)
|
||
return rows
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 智能填单:LLM 结构化解析
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class LLMOrderParser:
|
||
"""调用通义千问 qwen-plus 进行订单文本结构化解析。
|
||
|
||
支持纯文本和 OCR 图片两种输入模式,通过 system prompt 引导 LLM 输出标准 JSON 格式的订单信息。
|
||
内置产品库提示词(product_hints),帮助 LLM 匹配产品名称和规格。
|
||
"""
|
||
|
||
TEXT_SYSTEM_PROMPT = """你是订单信息解析助手。从用户提供的文本中提取订单信息。
|
||
|
||
严格按以下 JSON 格式输出,不要输出其他内容:
|
||
{
|
||
"customer_name": "客户姓名",
|
||
"customer_mobile": "手机号",
|
||
"customer_address": "地址",
|
||
"order_source": "订单来源(如能识别)",
|
||
"delivery_type": "配送方式(如能识别)",
|
||
"remark": "备注",
|
||
"items": [
|
||
{
|
||
"product_name": "产品名称",
|
||
"specification": "规格",
|
||
"unit": "单位",
|
||
"quantity": 数量数字,
|
||
"sale_price": 单价数字
|
||
}
|
||
]
|
||
}
|
||
|
||
规则:
|
||
- 手机号必须是 11 位数字,以 1 开头
|
||
- 数量和价格必须是数字(不是字符串)
|
||
- 无法识别的字段填 null,不要编造
|
||
- 如果文本中有多个产品,每个产品一个 items 条目
|
||
|
||
可选的产品库(名称 + 规格):
|
||
{product_hints}"""
|
||
|
||
IMAGE_SYSTEM_PROMPT = """你是订单信息解析助手。OCR 系统已经从图片中提取了文本并做了初步分析。
|
||
你需要基于 OCR 的结果,补充和完善订单信息。
|
||
|
||
OCR 已提取的信息:
|
||
- OCR 置信度:{ocr_confidence}
|
||
- 已识别的客户姓名:{customer_name}
|
||
- 已识别的客户手机:{customer_mobile}
|
||
- 已识别的客户地址:{customer_address}
|
||
- 产品表格区域识别到的原始行:
|
||
{table_rows}
|
||
|
||
你需要完成:
|
||
1. 验证 OCR 提取的字段是否合理
|
||
2. 从原始文本中补充 OCR 未提取到的字段
|
||
3. 解析产品明细(如果 OCR 表格区域数据可用,优先使用)
|
||
4. 识别订单来源、配送方式等附加信息
|
||
|
||
严格按以下 JSON 格式输出:
|
||
{
|
||
"customer_name": "客户姓名",
|
||
"customer_mobile": "手机号",
|
||
"customer_address": "地址",
|
||
"order_source": "订单来源",
|
||
"delivery_type": "配送方式",
|
||
"remark": "备注",
|
||
"items": [
|
||
{
|
||
"product_name": "产品名称",
|
||
"specification": "规格",
|
||
"unit": "单位",
|
||
"quantity": 数量数字,
|
||
"sale_price": 单价数字
|
||
}
|
||
]
|
||
}
|
||
|
||
规则:
|
||
- 无法识别的字段填 null,不要编造
|
||
- 如果 OCR 已提取的字段看起来正确,直接沿用
|
||
|
||
可选的产品库(名称 + 规格):
|
||
{product_hints}"""
|
||
|
||
def _build_product_hints(self, product_groups: list[dict]) -> str:
|
||
"""构建产品库提示词,供 LLM 在解析时参考。
|
||
|
||
参数:
|
||
product_groups: 产品分组列表,每组包含 product_name 和 specifications。
|
||
|
||
返回:
|
||
格式化的产品提示文本,最多 30 个产品,每个最多 5 个规格。
|
||
"""
|
||
hints: list[str] = []
|
||
for group in product_groups[:30]:
|
||
specs = ', '.join(
|
||
f"{s['specification']}({s['unit']})"
|
||
for s in group.get("specifications", [])[:5]
|
||
)
|
||
hints.append(f"- {group['product_name']}: {specs}")
|
||
return '\n'.join(hints) or "(产品库为空)"
|
||
|
||
def parse(self, text: str, product_groups: list[dict],
|
||
api_key: str, api_url: str,
|
||
ocr_context: dict | None = None) -> dict:
|
||
"""调用 LLM 解析订单文本,返回结构化订单数据。
|
||
|
||
参数:
|
||
text: 待解析的订单文本。
|
||
product_groups: 产品库数据,用于构建提示词。
|
||
api_key: LLM API 访问密钥。
|
||
api_url: LLM API 地址。
|
||
ocr_context: 可选的 OCR 上下文,图片模式时提供版面分析结果。
|
||
|
||
返回:
|
||
包含 customer_name、customer_mobile、items 等字段的订单字典。
|
||
|
||
异常:
|
||
LLM 调用失败或返回格式异常时抛出 AppException。
|
||
"""
|
||
product_hints = self._build_product_hints(product_groups)
|
||
if ocr_context:
|
||
system_prompt = self.IMAGE_SYSTEM_PROMPT.format(
|
||
ocr_confidence=ocr_context.get("ocr_confidence", "N/A"),
|
||
customer_name=ocr_context.get("pre_filled", {}).get("customer_name", "未识别"),
|
||
customer_mobile=ocr_context.get("pre_filled", {}).get("customer_mobile", "未识别"),
|
||
customer_address=ocr_context.get("pre_filled", {}).get("customer_address", "未识别"),
|
||
table_rows='\n'.join(
|
||
f" - {row['raw']}" for row in ocr_context.get("table_rows", [])
|
||
) or " 无",
|
||
product_hints=product_hints,
|
||
)
|
||
else:
|
||
system_prompt = self.TEXT_SYSTEM_PROMPT.format(product_hints=product_hints)
|
||
|
||
payload = json.dumps({
|
||
"model": "qwen-plus",
|
||
"messages": [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": f"请解析以下订单内容:\n\n{text}"},
|
||
],
|
||
"temperature": 0.1,
|
||
"max_tokens": 1024,
|
||
}).encode("utf-8")
|
||
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {api_key}",
|
||
}
|
||
req = urllib_request.Request(
|
||
url=api_url, data=payload, headers=headers, method="POST"
|
||
)
|
||
with urllib_request.urlopen(req, timeout=30) as resp:
|
||
result = json.loads(resp.read().decode("utf-8"))
|
||
|
||
content = result["choices"][0]["message"]["content"]
|
||
return self._extract_json(content)
|
||
|
||
def _extract_json(self, text: str) -> dict:
|
||
"""从 LLM 响应文本中提取 JSON 内容。
|
||
|
||
处理 LLM 可能包裹在 markdown 代码块中的情况。
|
||
|
||
参数:
|
||
text: LLM 原始响应文本。
|
||
|
||
返回:
|
||
解析后的字典。
|
||
"""
|
||
text = text.strip()
|
||
if text.startswith("```"):
|
||
text = text.split("\n", 1)[1]
|
||
text = text.rsplit("```", 1)[0]
|
||
return json.loads(text.strip())
|
||
|
||
def safe_parse(self, text: str, product_groups: list[dict],
|
||
api_key: str, api_url: str,
|
||
ocr_context: dict | None = None) -> dict | None:
|
||
"""安全版解析入口,异常时返回 None 而非抛出异常。
|
||
|
||
参数:
|
||
text: 待解析的订单文本。
|
||
product_groups: 产品库数据。
|
||
api_key: LLM API 访问密钥。
|
||
api_url: LLM API 地址。
|
||
ocr_context: 可选的 OCR 上下文。
|
||
|
||
返回:
|
||
解析后的订单字典,失败时返回 None。
|
||
"""
|
||
try:
|
||
return self.parse(text, product_groups, api_key, api_url, ocr_context)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
# 避免和后面 urllib_request 冲突,这里在文件顶部已经 import 了
|
||
urllib_request = request
|
||
|
||
|
||
class BaseOCRProvider:
|
||
"""OCR 提供者基类,定义统一的识别接口。
|
||
|
||
子类需实现 recognize 方法,返回 (raw_result, suggested_result, confidence) 三元组。
|
||
"""
|
||
provider_name = "base"
|
||
|
||
def recognize(self, image_url: str, biz_type: str, biz_id: int) -> tuple[dict, dict, float]:
|
||
"""识别图片中的文字内容。
|
||
|
||
参数:
|
||
image_url: 图片 URL 地址。
|
||
biz_type: 业务类型标识。
|
||
biz_id: 业务记录 ID。
|
||
|
||
返回:
|
||
三元组 (原始识别结果, 建议结果, 置信度)。
|
||
"""
|
||
raise NotImplementedError
|
||
|
||
|
||
class MockOCRProvider(BaseOCRProvider):
|
||
"""模拟 OCR 提供者,用于开发调试和测试环境。
|
||
|
||
不调用真实 OCR 服务,直接返回固定格式的模拟结果,置信度 0.92。
|
||
"""
|
||
provider_name = "mock_aliyun_adapter"
|
||
|
||
def recognize(self, image_url: str, biz_type: str, biz_id: int) -> tuple[dict, dict, float]:
|
||
"""返回模拟的 OCR 识别结果。
|
||
|
||
参数:
|
||
image_url: 图片 URL 地址。
|
||
biz_type: 业务类型标识。
|
||
biz_id: 业务记录 ID。
|
||
|
||
返回:
|
||
三元组 (模拟原始结果, 模拟建议结果, 0.92)。
|
||
"""
|
||
# 模拟 OCR 识别出的文本行,用于开发调试时走通完整解析流程
|
||
sample_lines = [
|
||
"张三 13800138000",
|
||
"广东省深圳市南山区科技园路88号",
|
||
"产品名称:工业级密封圈",
|
||
"规格:DN50 数量:100件 单价:15.5",
|
||
"合计金额:1550.00",
|
||
"备注:尽快发货",
|
||
]
|
||
suggested_result = {
|
||
"image_name": image_url.rsplit("/", 1)[-1],
|
||
"biz_type": biz_type,
|
||
"biz_id": biz_id,
|
||
"line_list": sample_lines,
|
||
"recognized_text": "\n".join(sample_lines),
|
||
}
|
||
raw_result = {
|
||
"provider": self.provider_name,
|
||
"image_url": image_url,
|
||
"fields": suggested_result,
|
||
}
|
||
return raw_result, suggested_result, 0.92
|
||
|
||
|
||
class AliyunOCRProvider(BaseOCRProvider):
|
||
"""阿里云 OCR 提供者,通过官方 SDK 调用阿里云文字识别服务。
|
||
|
||
依赖 settings 中的 aliyun_ai_access_key_id、aliyun_ai_access_key_secret、
|
||
aliyun_ai_region 等配置。使用 alibabacloud_ocr_api20210707 SDK。
|
||
"""
|
||
provider_name = "aliyun_ocr"
|
||
|
||
ENDPOINT = "ocr-api.cn-hangzhou.aliyuncs.com"
|
||
|
||
def __init__(self, settings) -> None:
|
||
self.settings = settings
|
||
|
||
def recognize(self, image_url: str, biz_type: str, biz_id: int) -> tuple[dict, dict, float]:
|
||
"""调用阿里云 OCR 识别图片中的文字。
|
||
|
||
参数:
|
||
image_url: 图片 URL 地址。
|
||
biz_type: 业务类型标识。
|
||
biz_id: 业务记录 ID。
|
||
|
||
返回:
|
||
三元组 (原始 API 响应, 结构化建议结果, 置信度)。
|
||
"""
|
||
payload = self._call_aliyun(image_url)
|
||
raw_result = {
|
||
"provider": self.provider_name,
|
||
"region": self.settings.aliyun_ai_region,
|
||
"image_url": image_url,
|
||
"payload": payload,
|
||
}
|
||
suggested_result = self._build_suggested_result(payload, image_url, biz_type, biz_id)
|
||
confidence = self._extract_confidence(payload)
|
||
return raw_result, suggested_result, confidence
|
||
|
||
def _get_client(self):
|
||
"""构建阿里云 OCR SDK 客户端。"""
|
||
from alibabacloud_ocr_api20210707.client import Client as OcrClient
|
||
from alibabacloud_tea_openapi.models import Config
|
||
|
||
config = Config(
|
||
access_key_id=self.settings.aliyun_ai_access_key_id,
|
||
access_key_secret=self.settings.aliyun_ai_access_key_secret,
|
||
endpoint=self.ENDPOINT,
|
||
)
|
||
return OcrClient(config)
|
||
|
||
def _call_aliyun(self, image_url: str) -> dict:
|
||
"""通过官方 SDK 调用阿里云 OCR 接口。
|
||
|
||
先从 URL 下载图片字节,再通过 SDK 发送识别请求。
|
||
|
||
参数:
|
||
image_url: 图片 URL 地址。
|
||
|
||
返回:
|
||
阿里云 OCR SDK 的响应字典。
|
||
|
||
异常:
|
||
下载图片失败或 SDK 调用失败时抛出 AppException。
|
||
"""
|
||
from alibabacloud_ocr_api20210707.models import RecognizeGeneralRequest
|
||
from urllib import request as urllib_request, error as urllib_error
|
||
from urllib.parse import urlparse, quote, urlunparse
|
||
|
||
# 对 URL 中的非 ASCII 字符(如中文文件名)进行编码,避免 ASCII 编码异常
|
||
parsed = urlparse(image_url)
|
||
safe_url = urlunparse(parsed._replace(path=quote(parsed.path)))
|
||
|
||
try:
|
||
with urllib_request.urlopen(safe_url, timeout=15) as resp:
|
||
image_bytes = resp.read()
|
||
except (urllib_error.HTTPError, urllib_error.URLError) as exc:
|
||
raise AppException(
|
||
code=ErrorCode.THIRD_PARTY_FAILED,
|
||
message=f"下载 OCR 图片失败:{exc}",
|
||
status_code=400,
|
||
) from exc
|
||
|
||
try:
|
||
client = self._get_client()
|
||
ocr_request = RecognizeGeneralRequest(body=image_bytes)
|
||
response = client.recognize_general(ocr_request)
|
||
except Exception as exc:
|
||
raise AppException(
|
||
code=ErrorCode.THIRD_PARTY_FAILED,
|
||
message=f"阿里云 OCR SDK 调用失败:{exc}",
|
||
status_code=400,
|
||
) from exc
|
||
|
||
body = response.body
|
||
if hasattr(body, "to_map"):
|
||
payload = body.to_map()
|
||
elif isinstance(body, dict):
|
||
payload = body
|
||
else:
|
||
payload = json.loads(str(body)) if body else {}
|
||
|
||
# SDK 返回 Data 为 JSON 字符串,解析为 dict 便于后续统一提取
|
||
if isinstance(payload.get("Data"), str):
|
||
try:
|
||
payload["Data"] = json.loads(payload["Data"])
|
||
except (json.JSONDecodeError, TypeError):
|
||
pass
|
||
|
||
return payload
|
||
|
||
def _build_suggested_result(self, payload: dict, image_url: str, biz_type: str, biz_id: int) -> dict:
|
||
"""将阿里云 OCR 原始响应转换为统一的结构化建议结果。
|
||
|
||
从响应中递归提取文本行,拼接为 recognized_text 和 line_list。
|
||
|
||
参数:
|
||
payload: 阿里云 OCR API 响应字典。
|
||
image_url: 图片 URL 地址。
|
||
biz_type: 业务类型标识。
|
||
biz_id: 业务记录 ID。
|
||
|
||
返回:
|
||
包含 image_name、biz_type、recognized_text、line_list 等字段的建议结果字典。
|
||
"""
|
||
# 不强依赖某个固定响应字段,先把常见 OCR 结果平铺为统一结构,便于前端先联调。
|
||
texts = self._collect_texts(payload)
|
||
result = {
|
||
"image_name": image_url.rsplit("/", 1)[-1],
|
||
"biz_type": biz_type,
|
||
"biz_id": biz_id,
|
||
"recognized_text": "\n".join(texts).strip(),
|
||
"line_list": texts,
|
||
}
|
||
if texts:
|
||
result["customer_name"] = texts[0][:50]
|
||
return result
|
||
|
||
def _collect_texts(self, value) -> list[str]:
|
||
"""从嵌套的字典/列表结构中递归提取所有文本值并去重。
|
||
|
||
识别常见 OCR 响应字段名(text、content、word、words 等),
|
||
跳过空值和重复文本。
|
||
|
||
参数:
|
||
value: OCR 响应中的任意嵌套结构。
|
||
|
||
返回:
|
||
去重后的文本列表,保持原始出现顺序。
|
||
"""
|
||
texts: list[str] = []
|
||
if isinstance(value, dict):
|
||
for key, item in value.items():
|
||
lowered = str(key).lower()
|
||
if lowered in {"text", "content", "word", "words", "value", "label", "title"} and isinstance(item, str):
|
||
stripped = item.strip()
|
||
if stripped:
|
||
texts.append(stripped)
|
||
else:
|
||
texts.extend(self._collect_texts(item))
|
||
elif isinstance(value, list):
|
||
for item in value:
|
||
texts.extend(self._collect_texts(item))
|
||
elif isinstance(value, str):
|
||
stripped = value.strip()
|
||
if stripped:
|
||
texts.append(stripped)
|
||
deduplicated: list[str] = []
|
||
seen: set[str] = set()
|
||
for text in texts:
|
||
if text not in seen:
|
||
seen.add(text)
|
||
deduplicated.append(text)
|
||
return deduplicated
|
||
|
||
def _extract_confidence(self, payload: dict) -> float:
|
||
"""从 OCR 响应中提取置信度分数。
|
||
|
||
依次尝试 confidence、score、probability 字段,未找到时默认返回 0.9。
|
||
|
||
参数:
|
||
payload: OCR API 响应字典。
|
||
|
||
返回:
|
||
置信度浮点数(0.0 ~ 1.0)。
|
||
"""
|
||
for key in ("confidence", "score", "probability"):
|
||
value = payload.get(key)
|
||
if isinstance(value, (int, float)):
|
||
return float(value)
|
||
if isinstance(value, str):
|
||
try:
|
||
return float(value)
|
||
except ValueError:
|
||
continue
|
||
return 0.9
|
||
|
||
|
||
class AIService:
|
||
"""AI 智能业务服务入口。
|
||
|
||
整合 OCR 识别、规则提取、LLM 解析、产品库匹配等能力,
|
||
提供图片识别(recognize_image)、识别结果修正(correct_result)、
|
||
智能填单(parse_order)三大对外接口。
|
||
|
||
依赖:
|
||
- AIRepository:AI 识别日志的存储。
|
||
- get_settings:系统配置,包含 API 密钥和 provider 选择。
|
||
- AuditService:操作审计日志记录。
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self.repository = AIRepository()
|
||
self.settings = get_settings()
|
||
|
||
def recognize_image(self, payload: dict, session: Session | None = None) -> dict:
|
||
"""调用 OCR 识别图片中的文字并保存识别日志。
|
||
|
||
参数:
|
||
payload: 请求数据字典,必含 image_url、biz_type,可选 biz_id。
|
||
session: 数据库会话,不可为 None。
|
||
|
||
返回:
|
||
包含 log_id、raw_result、confidence、suggested_result 的字典。
|
||
|
||
被调用方:ai 路由(图片识别接口)。
|
||
"""
|
||
image_url = (payload.get("image_url") or "").strip()
|
||
biz_type = (payload.get("biz_type") or "").strip()
|
||
biz_id = payload.get("biz_id")
|
||
|
||
if not image_url:
|
||
raise AppException(code=ErrorCode.PARAM_ERROR, message="图片地址不能为空", status_code=400)
|
||
if not biz_type:
|
||
raise AppException(code=ErrorCode.PARAM_ERROR, message="业务类型不能为空", status_code=400)
|
||
if image_url.startswith("mock://fail"):
|
||
raise AppException(code=ErrorCode.THIRD_PARTY_FAILED, message="AI 识别失败", status_code=400)
|
||
|
||
provider = self._build_provider()
|
||
raw_result, suggested_result, confidence = provider.recognize(image_url, biz_type, int(biz_id or 0))
|
||
|
||
if session is not None:
|
||
try:
|
||
# 识别原始响应和建议结果都落库,后面人工修正和问题回溯都依赖这份快照。
|
||
log = self.repository.create_log(
|
||
session,
|
||
{
|
||
"biz_type": biz_type,
|
||
"biz_id": biz_id,
|
||
"image_url": image_url,
|
||
"raw_result": json.dumps(raw_result, ensure_ascii=False),
|
||
"confidence": Decimal(str(confidence)),
|
||
"corrected_result": None,
|
||
"created_by": None,
|
||
},
|
||
)
|
||
audit_service.write_log(
|
||
session,
|
||
{
|
||
"operate_type": "ai_recognize",
|
||
"biz_type": biz_type,
|
||
"biz_id": biz_id,
|
||
"before_value": None,
|
||
"after_value": {
|
||
"log_id": log.id,
|
||
"provider": raw_result.get("provider"),
|
||
"confidence": confidence,
|
||
"suggested_result": suggested_result,
|
||
},
|
||
"remark": f"AI识别图片 {image_url}",
|
||
},
|
||
)
|
||
session.commit()
|
||
return {
|
||
"log_id": log.id,
|
||
"raw_result": raw_result,
|
||
"confidence": confidence,
|
||
"suggested_result": suggested_result,
|
||
}
|
||
except AppException:
|
||
session.rollback()
|
||
raise
|
||
except SQLAlchemyError as exc:
|
||
session.rollback()
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
|
||
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
|
||
|
||
def correct_result(self, log_id: int, payload: dict, session: Session | None = None) -> dict:
|
||
"""人工修正 AI 识别结果并保存。
|
||
|
||
参数:
|
||
log_id: AI 识别日志 ID。
|
||
payload: 修正数据字典,必含 corrected_result(dict)。
|
||
session: 数据库会话,不可为 None。
|
||
|
||
返回:
|
||
包含 log_id 和 corrected 标志的字典。
|
||
|
||
被调用方:ai 路由(识别结果修正接口)。
|
||
"""
|
||
corrected_result = payload.get("corrected_result")
|
||
if not isinstance(corrected_result, dict) or not corrected_result:
|
||
raise AppException(code=ErrorCode.PARAM_ERROR, message="修正结果不能为空", status_code=400)
|
||
|
||
if session is not None:
|
||
try:
|
||
log = self.repository.get_log(session, log_id)
|
||
if log is None:
|
||
raise AppException(code=ErrorCode.NOT_FOUND, message="识别记录不存在", status_code=404)
|
||
before_value = self._safe_load_json(log.corrected_result)
|
||
log.corrected_result = json.dumps(corrected_result, ensure_ascii=False)
|
||
session.add(log)
|
||
audit_service.write_log(
|
||
session,
|
||
{
|
||
"operate_type": "ai_correct",
|
||
"biz_type": log.biz_type,
|
||
"biz_id": log.biz_id,
|
||
"before_value": before_value,
|
||
"after_value": corrected_result,
|
||
"remark": f"修正AI识别结果 {log_id}",
|
||
},
|
||
)
|
||
session.commit()
|
||
return {"log_id": log.id, "corrected": True}
|
||
except AppException:
|
||
session.rollback()
|
||
raise
|
||
except SQLAlchemyError as exc:
|
||
session.rollback()
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
|
||
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
|
||
|
||
def _build_provider(self) -> BaseOCRProvider:
|
||
"""根据系统配置构建对应的 OCR 提供者实例。
|
||
|
||
当 ai_provider 配置为 'aliyun' 时返回 AliyunOCRProvider,否则返回 MockOCRProvider。
|
||
|
||
返回:
|
||
OCR 提供者实例。
|
||
"""
|
||
provider_name = self.settings.ai_provider.strip().lower()
|
||
# provider 选择集中在这里,后续扩展腾讯云或本地 OCR 时不需要动主流程。
|
||
if provider_name == "aliyun":
|
||
return AliyunOCRProvider(self.settings)
|
||
return MockOCRProvider()
|
||
|
||
def _safe_load_json(self, value: str | None):
|
||
"""安全解析 JSON 字符串,异常时返回原始值或 None。
|
||
|
||
参数:
|
||
value: JSON 字符串或 None。
|
||
|
||
返回:
|
||
解析后的对象,解析失败返回原始字符串,空值返回 None。
|
||
"""
|
||
if not value:
|
||
return None
|
||
try:
|
||
return json.loads(value)
|
||
except json.JSONDecodeError:
|
||
return value
|
||
|
||
# ------------------------------------------------------------------
|
||
# 智能填单:核心解析入口
|
||
# ------------------------------------------------------------------
|
||
|
||
def parse_order(self, payload: dict, session: Session | None = None) -> dict:
|
||
"""智能填单核心入口:从文本或图片中解析订单信息。
|
||
|
||
处理流程:
|
||
1. 获取原始文本(图片模式先调 OCR)
|
||
2. 文本预处理(去噪声)
|
||
3. 规则提取(正则提取手机号、数量、价格、地址)
|
||
4. LLM 结构化解析
|
||
5. 规则结果与 LLM 结果合并
|
||
6. 客户库预匹配
|
||
7. 产品库模糊匹配
|
||
8. 校验并计算置信度
|
||
|
||
参数:
|
||
payload: 请求数据字典,必含 input_type('text' 或 'image'),
|
||
text 模式需提供 text 字段,image 模式需提供 image_url 字段。
|
||
session: 数据库会话,为 None 时使用 mock 模式。
|
||
|
||
返回:
|
||
包含 parsed_order、raw_text、confidence、parse_source、product_matches、
|
||
factory_options、warnings 的字典。
|
||
|
||
被调用方:ai 路由(智能填单接口)。
|
||
"""
|
||
input_type = payload["input_type"]
|
||
|
||
# 1. 获取原始文本 + OCR 中间结果
|
||
ocr_context = None
|
||
if input_type == "image":
|
||
ocr_context = self._ocr_and_parse_image(payload["image_url"])
|
||
raw_text = ocr_context["raw_text"]
|
||
else:
|
||
raw_text = payload["text"].strip()
|
||
|
||
# 2. 文本预处理
|
||
preprocessor = TextPreprocessor()
|
||
raw_text = preprocessor.preprocess(raw_text)
|
||
if not raw_text:
|
||
raise AppException(
|
||
code=ErrorCode.PARAM_ERROR,
|
||
message="无法提取到文本内容",
|
||
status_code=400,
|
||
)
|
||
|
||
# 3. 正则提取
|
||
rule_extractor = RuleExtractor()
|
||
rule_result = rule_extractor.extract(raw_text)
|
||
if ocr_context and ocr_context.get("pre_filled"):
|
||
for key, value in ocr_context["pre_filled"].items():
|
||
if value and not rule_result.get(key):
|
||
rule_result[key] = value
|
||
|
||
# 4. LLM 结构化解析
|
||
settings = self.settings
|
||
product_groups = self._get_product_groups(session)
|
||
llm_parser = LLMOrderParser()
|
||
llm_result = llm_parser.safe_parse(
|
||
raw_text,
|
||
product_groups,
|
||
settings.llm_parse_api_key or settings.aliyun_ai_access_key_id,
|
||
settings.llm_parse_api_url,
|
||
ocr_context=ocr_context,
|
||
)
|
||
|
||
# 5. 合并
|
||
if llm_result:
|
||
merged = self._merge_results(rule_result, llm_result)
|
||
parse_source = "hybrid"
|
||
else:
|
||
merged = self._build_fallback_result(rule_result)
|
||
parse_source = "rule"
|
||
|
||
# 6. 客户库预匹配
|
||
if session is not None and merged.get("customer_mobile"):
|
||
try:
|
||
from backend.app.repositories.customer_repository import CustomerRepository
|
||
customer_repo = CustomerRepository()
|
||
existing_customer = customer_repo.find_by_mobile(
|
||
session, merged["customer_mobile"]
|
||
)
|
||
if existing_customer:
|
||
merged["customer_id"] = existing_customer.id
|
||
if existing_customer.address and not merged.get("customer_address"):
|
||
merged["customer_address"] = existing_customer.address
|
||
except Exception:
|
||
pass
|
||
|
||
# 7. 产品库模糊匹配
|
||
product_matches = self._match_products(
|
||
merged.get("items", []), product_groups, session
|
||
)
|
||
|
||
# 8. 校验 + 置信度
|
||
warnings = self._validate_parsed_order(merged)
|
||
if parse_source == "rule":
|
||
warnings.append("LLM 服务不可用,仅使用规则提取,部分字段可能不完整")
|
||
ocr_conf = ocr_context["ocr_confidence"] if ocr_context else None
|
||
confidence = self._calc_confidence(merged, warnings, ocr_confidence=ocr_conf)
|
||
|
||
return {
|
||
"parsed_order": merged,
|
||
"raw_text": raw_text,
|
||
"confidence": confidence,
|
||
"ocr_confidence": ocr_conf,
|
||
"parse_source": parse_source,
|
||
"is_mock": session is None,
|
||
"product_matches": product_matches,
|
||
"factory_options": self._get_factory_options(session),
|
||
"warnings": warnings,
|
||
}
|
||
|
||
def _ocr_and_parse_image(self, image_url: str) -> dict:
|
||
"""调用 OCR 识别图片并进行版面分析。
|
||
|
||
参数:
|
||
image_url: 图片 URL 地址。
|
||
|
||
返回:
|
||
OrderOCRParser.parse() 的返回结果,包含 raw_text、pre_filled、table_rows 等。
|
||
"""
|
||
provider = self._build_provider()
|
||
raw_result, suggested_result, ocr_confidence = provider.recognize(
|
||
image_url, "order_parse", 0
|
||
)
|
||
line_list = suggested_result.get("line_list", [])
|
||
if not line_list:
|
||
text = suggested_result.get("recognized_text", "")
|
||
line_list = [line for line in text.split("\n") if line.strip()]
|
||
ocr_parser = OrderOCRParser()
|
||
return ocr_parser.parse(line_list, ocr_confidence)
|
||
|
||
def _merge_results(self, rule_result: dict, llm_result: dict) -> dict:
|
||
"""合并规则提取结果和 LLM 解析结果。
|
||
|
||
以 LLM 结果为基础,规则提取的手机号优先使用,地址取较长者。
|
||
|
||
参数:
|
||
rule_result: 规则提取结果。
|
||
llm_result: LLM 解析结果。
|
||
|
||
返回:
|
||
合并后的订单字典。
|
||
"""
|
||
merged = dict(llm_result)
|
||
if rule_result.get("customer_mobile"):
|
||
merged["customer_mobile"] = rule_result["customer_mobile"]
|
||
rule_addr = rule_result.get("customer_address", "")
|
||
llm_addr = merged.get("customer_address", "")
|
||
if rule_addr and len(rule_addr) > len(llm_addr or ""):
|
||
merged["customer_address"] = rule_addr
|
||
return merged
|
||
|
||
def _build_fallback_result(self, rule_result: dict) -> dict:
|
||
"""当 LLM 不可用时,仅基于规则提取结果构建订单数据。
|
||
|
||
参数:
|
||
rule_result: 规则提取结果。
|
||
|
||
返回:
|
||
包含基础订单字段的字典,产品明细可能不完整。
|
||
"""
|
||
items: list[dict] = []
|
||
if rule_result.get("_raw_quantity"):
|
||
items.append({
|
||
"product_name": "",
|
||
"specification": "",
|
||
"unit": rule_result.get("_raw_unit", ""),
|
||
"quantity": rule_result["_raw_quantity"],
|
||
"sale_price": rule_result.get("_raw_price", 0),
|
||
"cost_price": None,
|
||
})
|
||
return {
|
||
"customer_name": rule_result.get("customer_name"),
|
||
"customer_mobile": rule_result.get("customer_mobile"),
|
||
"customer_address": rule_result.get("customer_address"),
|
||
"order_source": None,
|
||
"delivery_type": None,
|
||
"factory_id": None,
|
||
"remark": None,
|
||
"items": items,
|
||
}
|
||
|
||
def _get_product_groups(self, session: Session | None) -> list[dict]:
|
||
"""获取产品库数据,用于构建 LLM 提示词和模糊匹配。
|
||
|
||
参数:
|
||
session: 数据库会话,为 None 时返回空列表。
|
||
|
||
返回:
|
||
产品分组列表。
|
||
"""
|
||
if session is None:
|
||
return []
|
||
try:
|
||
from backend.app.services.product_service import product_service
|
||
result = product_service.list_products({}, session)
|
||
return result.get("list", [])
|
||
except Exception:
|
||
return []
|
||
|
||
def _match_products(self, items: list[dict],
|
||
product_groups: list[dict],
|
||
session: Session | None) -> list[dict]:
|
||
"""对解析出的产品明细进行库内模糊匹配。
|
||
|
||
使用 SequenceMatcher 计算名称(权重 0.6)和规格(权重 0.4)的相似度,
|
||
返回每个输入产品的前 3 个候选匹配(阈值 0.3)。
|
||
|
||
参数:
|
||
items: 解析出的产品明细列表。
|
||
product_groups: 产品库分组数据。
|
||
session: 数据库会话。
|
||
|
||
返回:
|
||
匹配结果列表,每个元素包含 input_name、input_specification、candidates。
|
||
"""
|
||
flat_products: list[dict] = []
|
||
for group in product_groups:
|
||
for spec in group.get("specifications", []):
|
||
flat_products.append({
|
||
"product_id": spec["product_id"],
|
||
"product_name": group["product_name"],
|
||
"specification": spec["specification"],
|
||
"unit": spec["unit"],
|
||
"sale_price": spec.get("sale_price", 0),
|
||
"cost_price": spec.get("cost_price", 0),
|
||
})
|
||
matches: list[dict] = []
|
||
for item in items:
|
||
input_name = item.get("product_name", "")
|
||
input_spec = item.get("specification", "")
|
||
if not input_name:
|
||
continue
|
||
scored: list[tuple] = []
|
||
for prod in flat_products:
|
||
name_score = SequenceMatcher(None, input_name, prod["product_name"]).ratio()
|
||
spec_score = SequenceMatcher(None, input_spec, prod["specification"]).ratio() if input_spec else 0
|
||
combined = name_score * 0.6 + spec_score * 0.4
|
||
scored.append((prod, combined))
|
||
scored.sort(key=lambda x: x[1], reverse=True)
|
||
matches.append({
|
||
"input_name": input_name,
|
||
"input_specification": input_spec,
|
||
"candidates": [
|
||
{
|
||
"product_id": prod["product_id"],
|
||
"product_name": prod["product_name"],
|
||
"specification": prod["specification"],
|
||
"unit": prod["unit"],
|
||
"sale_price": prod["sale_price"],
|
||
"cost_price": prod["cost_price"],
|
||
"match_score": round(score, 2),
|
||
}
|
||
for prod, score in scored[:3] if score > 0.3
|
||
],
|
||
})
|
||
return matches
|
||
|
||
def _validate_parsed_order(self, order: dict) -> list[str]:
|
||
"""校验解析后的订单数据完整性,生成警告列表。
|
||
|
||
检查项:客户姓名、手机号、产品明细、产品名称、数量。
|
||
|
||
参数:
|
||
order: 解析后的订单字典。
|
||
|
||
返回:
|
||
警告信息列表,无警告时返回空列表。
|
||
"""
|
||
warnings: list[str] = []
|
||
if not order.get("customer_name"):
|
||
warnings.append("未识别到客户姓名")
|
||
if not order.get("customer_mobile"):
|
||
warnings.append("未识别到客户手机号")
|
||
if not order.get("items"):
|
||
warnings.append("未识别到产品明细")
|
||
for item in order.get("items", []):
|
||
if not item.get("product_name"):
|
||
warnings.append("存在产品名称为空的明细行")
|
||
if not item.get("quantity"):
|
||
warnings.append(f"产品 {item.get('product_name', '?')} 未识别到数量")
|
||
return warnings
|
||
|
||
def _calc_confidence(self, order: dict, warnings: list[str],
|
||
ocr_confidence: float | None = None) -> float:
|
||
"""计算综合置信度分数。
|
||
|
||
计算规则:以 OCR 置信度(默认 1.0)为基础,每个警告扣 0.1,
|
||
每个缺少单价的产品扣 0.05,最低不低于 0.1。
|
||
|
||
参数:
|
||
order: 解析后的订单字典。
|
||
warnings: 警告列表。
|
||
ocr_confidence: OCR 置信度,可选。
|
||
|
||
返回:
|
||
综合置信度(0.1 ~ 1.0)。
|
||
"""
|
||
base = 1.0
|
||
if ocr_confidence is not None:
|
||
base = ocr_confidence
|
||
base -= len(warnings) * 0.1
|
||
if order.get("items"):
|
||
for item in order["items"]:
|
||
if not item.get("sale_price"):
|
||
base -= 0.05
|
||
return round(max(base, 0.1), 2)
|
||
|
||
def _get_factory_options(self, session: Session | None) -> list[dict]:
|
||
"""获取工厂(供应商)下拉选项列表,供前端智能填单表单使用。
|
||
|
||
参数:
|
||
session: 数据库会话,为 None 时返回空列表。
|
||
|
||
返回:
|
||
包含 value(supplier_id)和 label(supplier_name)的字典列表。
|
||
"""
|
||
if session is None:
|
||
return []
|
||
try:
|
||
from backend.app.services.supplier_service import supplier_service
|
||
result = supplier_service.list_suppliers(
|
||
session, {"supplier_type": "factory"}
|
||
)
|
||
return [
|
||
{"value": item["supplier_id"], "label": item["supplier_name"]}
|
||
for item in result.get("list", [])
|
||
]
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
ai_service = AIService()
|