dingdanquanliucheng/backend/app/services/ai_service.py
2026-06-01 11:24:20 +08:00

1260 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""AI 智能服务模块
提供两大核心能力:
1. OCR 图片识别:将图片中的文字提取为结构化数据,支持阿里云 OCR 和 Mock 两种 provider。
2. 智能填单通过文本预处理、规则提取、LLM 结构化解析、产品库模糊匹配等多层管线,
从粘贴文本或图片中自动识别订单信息并填充到表单。
架构分层:
- TextPreprocessor清洗粘贴文本中的时间戳、表情标记等噪声。
- RuleExtractor基于正则的确定性字段提取手机号、数量、价格、地址
- OrderOCRParser基于 OCR 行列表做订单版面分析和字段提取。
- LLMOrderParser调用通义千问 qwen-plus 做订单文本结构化解析。
- BaseOCRProvider / AliyunOCRProvider / MockOCRProviderOCR 提供者抽象与实现。
- 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三大对外接口。
依赖:
- AIRepositoryAI 识别日志的存储。
- 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_resultdict
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 时返回空列表。
返回:
包含 valuesupplier_id和 labelsupplier_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()