提取共享函数read_image_bytes,当URL为/uploads/本地路径时 直接从文件系统读取,避免通过公网HTTP下载导致404。 统一修复AliyunOCRProvider和recognize_waybill两处图片下载。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2010 lines
80 KiB
Python
2010 lines
80 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 logging
|
||
import re
|
||
from decimal import Decimal
|
||
from difflib import SequenceMatcher
|
||
from urllib import error, parse, request
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
from sqlalchemy import select
|
||
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
|
||
|
||
|
||
def read_image_bytes(image_url: str) -> bytes:
|
||
"""读取图片字节。本地 /uploads/ 路径直接读文件,其他走 HTTP。"""
|
||
from pathlib import Path, PurePosixPath
|
||
from urllib.parse import urlparse
|
||
url_path = PurePosixPath(urlparse(image_url).path)
|
||
if len(url_path.parts) >= 2 and url_path.parts[1] == "uploads":
|
||
settings = get_settings()
|
||
return (Path(settings.local_upload_dir) / url_path.name).read_bytes()
|
||
with request.urlopen(image_url, timeout=15) as resp:
|
||
return resp.read()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 智能填单:文本预处理
|
||
# ---------------------------------------------------------------------------
|
||
|
||
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*(张|个|卷|条|套|片|块|吨|件|箱|包|米|cm|厘米|毫米|mm|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*(张|个|卷|条|套|片|块|吨|件|箱|包|米|cm|厘米|毫米|mm|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 = """你是高温布/特氟龙/胶带/输送带行业的订单解析助手。从业务员粘贴的文本中提取订单信息。
|
||
|
||
## 输入格式(非常重要)
|
||
业务员粘贴的文本由两列组成,中间用 tab 或多个空格分隔:
|
||
- 左列:产品描述(厚度 + 材料名 + 尺寸 + 数量 + 工艺要求)
|
||
- 右列:客户姓名,手机号,地址(备注)
|
||
|
||
示例:0.13mm 咖色高温布 1*10米 SYY+5681,13711561796,广东省广州市白云区石井街道
|
||
|
||
必须严格按这个结构拆分,不要把左列的产品信息混入右列的客户地址中。
|
||
|
||
## customer_address 规则(非常重要)
|
||
customer_address 只能包含地理地址信息(省/市/区/县/镇/街道/路/号/楼/室/栋/公司名),绝对不能包含:
|
||
- 产品描述(厚度、材料名、尺寸等)
|
||
- 客户姓名和手机号
|
||
- 订单编号(如 SYY+5681)
|
||
- 整段原始文本
|
||
|
||
## product_name 规则(非常重要)
|
||
product_name 只填产品材料类型名称,不要带型号编号:
|
||
- "0.13mm 咖色高温布" → product_name="高温布"
|
||
- "145g咖色高温布" → product_name="高温布"
|
||
- "黑色4*4mm网格输送带" → product_name="网格输送带"
|
||
- "咖色特氟龙胶带" → product_name="特氟龙胶带"
|
||
- "用凯夫拉布做" → product_name="凯夫拉布"
|
||
常见产品名:高温布、特氟龙胶带、网格输送带、烧烤网格片、凯夫拉布、硅胶布、胶带、输送带、揉面垫、硅胶烤垫
|
||
|
||
## specification 规则
|
||
specification 必须包含所有产品参数,格式为 "厚度/克重 颜色 标准化尺寸":
|
||
- "0.13mm 咖色高温布 1*10米" → specification="0.13mm 咖色 1000mm×10000mm"
|
||
- "145g咖色高温布 25*30厘米 1000张" → specification="145g 咖色 250mm×300mm"
|
||
|
||
## 尺寸标准化
|
||
将所有尺寸统一转换为 "长mm×宽mm" 格式:
|
||
- "1*10米" → "1000mm×10000mm"
|
||
- "60*80cm" → "600mm×800mm"
|
||
- "25*30厘米" → "250mm×300mm"
|
||
- "长800mm宽250mm" → "800mm×250mm"
|
||
- "80厘米宽*1米长" → "1000mm×800mm"
|
||
|
||
## 数量单位
|
||
常用单位:张、个、卷、条、套、片、块
|
||
|
||
## 备注提取
|
||
- 括号中的内容:(不要浅色的)、(单片卷)、(加急安排)→ items[].remark
|
||
- 尾部工艺要求:带回仓库包装、黑膜包边、牛鼻子接头 → items[].remark
|
||
|
||
## 严格按以下 JSON 格式输出:
|
||
{{
|
||
"customer_name": "客户姓名",
|
||
"customer_mobile": "11位手机号",
|
||
"customer_address": "仅包含地理地址",
|
||
"order_source": null,
|
||
"delivery_type": null,
|
||
"remark": "订单级备注",
|
||
"items": [
|
||
{{
|
||
"product_name": "产品材料类型(不带型号)",
|
||
"specification": "产品自身规格(厚度、颜色等标准参数)",
|
||
"demand_specification": "客户要求的尺寸,统一转换为m格式(如0.25m×0.3m),仅提取客户说的尺寸,无尺寸则为null",
|
||
"unit": "数量单位",
|
||
"quantity": 数量数字,
|
||
"sale_price": null,
|
||
"remark": "产品级备注"
|
||
}}
|
||
]
|
||
}}
|
||
|
||
## 示例
|
||
输入: 0.13mm 咖色高温布 1*10米 SYY+5681,13711561796,广东省 广州市 白云区 石井街道
|
||
输出: {{"customer_name":"SYY+5681","customer_mobile":"13711561796","customer_address":"广东省广州市白云区石井街道","remark":null,"items":[{{"product_name":"高温布","specification":"0.13mm 咖色","demand_specification":"1m×10m","unit":null,"quantity":null,"sale_price":null,"remark":null}}]}}
|
||
|
||
输入: 145g咖色高温布 25*30厘米 1000张 何莹,19932065916,河北省 邯郸市 大名县 园中街瑞盾门业
|
||
输出: {{"customer_name":"何莹","customer_mobile":"19932065916","customer_address":"河北省邯郸市大名县园中街瑞盾门业","remark":null,"items":[{{"product_name":"高温布","specification":"145g 咖色","demand_specification":"0.25m×0.3m","unit":"张","quantity":1000,"sale_price":null,"remark":null}}]}}
|
||
|
||
输入: 用凯夫拉布做,18丝布带。宽度22.8cm,长度56.5,两边重叠6mm 50个(重叠位置要加固) 徐阳飞,15158849480,浙江省 杭州市 富阳区 场口镇
|
||
输出: {{"customer_name":"徐阳飞","customer_mobile":"15158849480","customer_address":"浙江省杭州市富阳区场口镇","remark":null,"items":[{{"product_name":"凯夫拉布","specification":"18丝布带 重叠6mm","demand_specification":"0.228m×0.565m","unit":"个","quantity":50,"sale_price":null,"remark":"重叠位置要加固"}}]}}
|
||
|
||
可选的产品库(名称 + 规格):
|
||
{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[:50]:
|
||
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,
|
||
model: str = "qwen-plus") -> 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": model,
|
||
"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 代码块中的情况,
|
||
以及 LLM 响应被截断导致 JSON 不完整的常见场景。
|
||
|
||
参数:
|
||
text: LLM 原始响应文本。
|
||
|
||
返回:
|
||
解析后的字典。
|
||
"""
|
||
text = text.strip()
|
||
if text.startswith("```"):
|
||
text = text.split("\n", 1)[1]
|
||
text = text.rsplit("```", 1)[0]
|
||
text = text.strip()
|
||
try:
|
||
return json.loads(text)
|
||
except json.JSONDecodeError:
|
||
# LLM 响应可能被截断,尝试截断到最后一个完整 } 修复
|
||
last_brace = text.rfind("}")
|
||
while last_brace > 0:
|
||
try:
|
||
return json.loads(text[:last_brace + 1])
|
||
except json.JSONDecodeError:
|
||
last_brace = text.rfind("}", 0, last_brace - 1)
|
||
raise
|
||
|
||
def safe_parse(self, text: str, product_groups: list[dict],
|
||
api_key: str, api_url: str,
|
||
ocr_context: dict | None = None,
|
||
model: str = "qwen-plus") -> dict | None:
|
||
"""安全版解析入口,异常时返回 None 而非抛出异常。
|
||
|
||
参数:
|
||
text: 待解析的订单文本。
|
||
product_groups: 产品库数据。
|
||
api_key: LLM API 访问密钥。
|
||
api_url: LLM API 地址。
|
||
ocr_context: 可选的 OCR 上下文。
|
||
model: LLM 模型名称。
|
||
|
||
返回:
|
||
解析后的订单字典,失败时返回 None。
|
||
"""
|
||
try:
|
||
return self.parse(text, product_groups, api_key, api_url, ocr_context, model=model)
|
||
except Exception:
|
||
logger.warning("[LLM] 解析失败", exc_info=True)
|
||
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
|
||
|
||
try:
|
||
image_bytes = read_image_bytes(image_url)
|
||
except Exception 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. 多订单切分(text 模式下,按手机号锚点拆分为独立订单段)
|
||
chunks = self._split_orders(raw_text) if input_type == "text" else [raw_text]
|
||
|
||
# 4. 正则提取(整段提取手机号等确定性字段)
|
||
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
|
||
|
||
# 5. LLM 结构化解析(每个订单段独立解析)
|
||
settings = self.settings
|
||
product_groups = self._get_product_groups(session)
|
||
logger.info("[智能填单] product_groups 数量: %d", len(product_groups))
|
||
if product_groups:
|
||
logger.info("[智能填单] 产品列表: %s", [g["product_name"] for g in product_groups[:10]])
|
||
llm_parser = LLMOrderParser()
|
||
|
||
if len(chunks) == 1:
|
||
llm_result = llm_parser.safe_parse(
|
||
chunks[0], product_groups,
|
||
settings.llm_parse_api_key or settings.aliyun_ai_access_key_id,
|
||
settings.llm_parse_api_url,
|
||
ocr_context=ocr_context,
|
||
model=settings.llm_parse_model,
|
||
)
|
||
else:
|
||
# 多段时逐段解析,合并 items
|
||
all_items = []
|
||
merged_customer = {}
|
||
for chunk in chunks:
|
||
chunk_result = llm_parser.safe_parse(
|
||
chunk, product_groups,
|
||
settings.llm_parse_api_key or settings.aliyun_ai_access_key_id,
|
||
settings.llm_parse_api_url,
|
||
model=settings.llm_parse_model,
|
||
)
|
||
if chunk_result:
|
||
all_items.extend(chunk_result.get("items", []))
|
||
for key in ("customer_name", "customer_mobile", "customer_address", "remark"):
|
||
if chunk_result.get(key) and not merged_customer.get(key):
|
||
merged_customer[key] = chunk_result[key]
|
||
llm_result = {**merged_customer, "items": all_items} if all_items else None
|
||
|
||
# 5. 合并
|
||
if llm_result:
|
||
# 标准化 demand_specification 中的单位格式
|
||
for item in llm_result.get("items", []):
|
||
if item.get("demand_specification"):
|
||
item["demand_specification"] = self._standardize_demand_specification(
|
||
item["demand_specification"]
|
||
)
|
||
merged = self._merge_results(rule_result, llm_result)
|
||
parse_source = "hybrid"
|
||
else:
|
||
merged = self._build_fallback_result(rule_result)
|
||
parse_source = "rule"
|
||
|
||
# 5.5 地址后处理:清理 LLM 可能混入的非地址内容
|
||
if merged.get("customer_address"):
|
||
merged["customer_address"] = self._clean_address(
|
||
merged["customer_address"], raw_text
|
||
)
|
||
if merged.get("customer_mobile") and not merged.get("customer_address"):
|
||
merged["customer_address"] = self._extract_address_after_phone(
|
||
raw_text, merged["customer_mobile"]
|
||
)
|
||
|
||
# 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
|
||
)
|
||
for pm in product_matches:
|
||
logger.info("[智能填单] 匹配: input=%s → 候选数=%d, 候选=%s",
|
||
pm["input_name"], len(pm["candidates"]),
|
||
[(c["product_name"], c["match_score"]) for c in pm["candidates"][:3]])
|
||
|
||
# 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,
|
||
}
|
||
|
||
_PHONE_ANCHOR_RE = re.compile(r'1[3-9]\d{9}')
|
||
|
||
def _split_orders(self, text: str) -> list[str]:
|
||
"""按手机号锚点将多条订单文本切分为独立段。
|
||
|
||
每个手机号标记一段客户信息的开始,手机号之前的行归为该段的产品行。
|
||
如果文本中只有一个手机号,返回原始文本(不分切)。
|
||
|
||
参数:
|
||
text: 预处理后的订单文本。
|
||
|
||
返回:
|
||
订单文本段列表,每段包含产品行和客户信息行。
|
||
"""
|
||
lines = text.split('\n')
|
||
anchors: list[tuple[int, str]] = []
|
||
for i, line in enumerate(lines):
|
||
match = self._PHONE_ANCHOR_RE.search(line)
|
||
if match:
|
||
anchors.append((i, match.group(0)))
|
||
if len(anchors) <= 1:
|
||
return [text]
|
||
chunks: list[str] = []
|
||
for idx, (line_idx, _phone) in enumerate(anchors):
|
||
start = anchors[idx - 1][0] + 1 if idx > 0 else 0
|
||
chunk_lines = lines[start:line_idx + 1]
|
||
chunk = '\n'.join(chunk_lines).strip()
|
||
if chunk:
|
||
chunks.append(chunk)
|
||
return chunks
|
||
|
||
_ADDRESS_KEYWORDS = {'省', '市', '区', '县', '镇', '路', '街', '号', '楼', '室', '栋', '村', '巷', '园'}
|
||
_ORDER_NO_RE = re.compile(r'[A-Z]{2,}[\d\-+]+') # SYY+5681, SO20260604 等订单号
|
||
|
||
def _clean_address(self, address: str, raw_text: str) -> str:
|
||
"""清理 LLM 返回的地址,去除混入的非地址内容。
|
||
|
||
如果地址中包含手机号或产品关键词,说明 LLM 没有正确拆分,
|
||
此时从原始文本中重新提取。
|
||
"""
|
||
if not address:
|
||
return address
|
||
# 如果地址中包含手机号,说明 LLM 把整段文本塞进了地址
|
||
if self._PHONE_ANCHOR_RE.search(address):
|
||
phone_match = self._PHONE_ANCHOR_RE.search(raw_text)
|
||
if phone_match:
|
||
return self._extract_address_after_phone(raw_text, phone_match.group(0))
|
||
return ""
|
||
# 去除订单号
|
||
address = self._ORDER_NO_RE.sub('', address).strip()
|
||
# 去除开头的逗号/空格
|
||
address = address.lstrip(',,、 \t')
|
||
# 如果清理后地址关键词密度太低,说明不是有效地址
|
||
addr_hits = sum(1 for kw in self._ADDRESS_KEYWORDS if kw in address)
|
||
if addr_hits < 1 and len(address) > 5:
|
||
# 可能是 LLM 误填,尝试从原始文本提取
|
||
phone_match = self._PHONE_ANCHOR_RE.search(raw_text)
|
||
if phone_match:
|
||
return self._extract_address_after_phone(raw_text, phone_match.group(0))
|
||
return address
|
||
|
||
def _extract_address_after_phone(self, text: str, phone: str) -> str:
|
||
"""从原始文本中提取手机号之后的地址信息。"""
|
||
idx = text.find(phone)
|
||
if idx < 0:
|
||
return ""
|
||
after = text[idx + len(phone):].strip()
|
||
# 去掉开头的逗号/空格/顿号
|
||
after = after.lstrip(',,、 \t')
|
||
return after if after else ""
|
||
|
||
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 提示词和模糊匹配。"""
|
||
if session is None:
|
||
return []
|
||
# 优先走 product_service(有缓存和分组逻辑)
|
||
try:
|
||
from backend.app.services.product_service import product_service
|
||
result = product_service.list_products({}, session)
|
||
groups = result.get("list", [])
|
||
if groups:
|
||
logger.info("[智能填单] _get_product_groups 返回 %d 个产品组", len(groups))
|
||
return groups
|
||
except Exception:
|
||
logger.warning("[智能填单] product_service 异常,尝试直查", exc_info=True)
|
||
# product_service 失败或返回空时,直查 Product 表兜底
|
||
try:
|
||
from backend.app.models.business import Product
|
||
stmt = select(Product).where(Product.deleted == 0).limit(100)
|
||
products = list(session.execute(stmt).scalars())
|
||
if products:
|
||
grouped: dict[str, dict] = {}
|
||
for p in products:
|
||
entry = grouped.setdefault(p.product_name, {
|
||
"product_name": p.product_name,
|
||
"product_id": p.id,
|
||
"specifications": [],
|
||
})
|
||
entry["specifications"].append({
|
||
"product_id": p.id,
|
||
"specification": p.specification,
|
||
"unit": p.unit,
|
||
"sale_price": float(p.sale_price or 0),
|
||
"cost_price": float(p.cost_price or 0),
|
||
})
|
||
logger.info("[智能填单] DB兜底: 从 Product 表直查到 %d 个产品组", len(grouped))
|
||
return list(grouped.values())
|
||
except Exception:
|
||
logger.warning("[智能填单] Product 表直查也失败", exc_info=True)
|
||
return []
|
||
|
||
_PRODUCT_ALIASES = {
|
||
"咖色": "咖啡色", "咖啡": "咖啡色",
|
||
"高温布": "高温布", "耐高温布": "高温布", "防火布": "高温布",
|
||
"网格带": "网格输送带",
|
||
"烧烤垫": "烧烤网格片",
|
||
"特氟龙": "特氟龙玻纤胶带",
|
||
}
|
||
|
||
# 输送带上下文关键词:命中任一则判定为输送带场景
|
||
_CONVEYOR_KEYWORDS = {
|
||
"包边", "红膜包边", "黑膜包边", "高温布包边", "凯夫拉包边",
|
||
"接头", "平接", "搭接", "斜接", "牛鼻子接头", "钢扣接头", "螺旋接头",
|
||
"上下盖布搭接", "上下盖布",
|
||
"导条", "凯夫拉导条", "硅胶导条", "四氟导条",
|
||
"柳丁", "鱼眼",
|
||
}
|
||
# 明确指向网格输送带的关键词
|
||
_CONVEYOR_MESH_KEYWORDS = {"网格", "网带", "特氟龙网格"}
|
||
|
||
# 胶带精确映射:关键词 -> 目标产品名
|
||
_TAPE_EXACT_MAP = {
|
||
"膜胶带": "特氟龙纯膜胶带",
|
||
}
|
||
_TAPE_FIBER_KEYWORDS = {"特氟龙胶带", "铁氟龙胶带", "小卷胶带"}
|
||
|
||
# 日用品高温布关键词(不含"高温布"本身,太泛会误命中工业场景)
|
||
_DAILY_CLOTH_KEYWORDS = {"拼豆", "烫布", "煤气灶垫", "烧烤垫"}
|
||
|
||
_PREFIX_STRIP_RE = re.compile(
|
||
r'^[\d.]+\s*(?:mm|cm|m|丝|g)\s*' # 厚度前缀: 0.13mm, 145g
|
||
r'|^(?:咖色|咖啡色|黑色|白色|灰色|红色|蓝色|绿色)' # 颜色前缀
|
||
)
|
||
|
||
# 数据库产品名带型号后缀,如 "高温布-SYY+5681",需去掉后缀再比较
|
||
_MODEL_SUFFIX_RE = re.compile(r'[-_][A-Za-z0-9+]+$')
|
||
|
||
# 厚度提取:匹配 "0.13mm"、"0.13"、"18丝" 等
|
||
_THICKNESS_RE = re.compile(r'(\d+\.\d+)\s*(?:mm|毫米|丝)?', re.IGNORECASE)
|
||
# 克重提取:匹配 "145g"、"145克" 等
|
||
_WEIGHT_RE = re.compile(r'(\d+)\s*(?:g|克|G)(?:\s|$|[^a-zA-Z])')
|
||
|
||
def _normalize_product_name(self, name: str) -> str:
|
||
"""去除产品名中的厚度/颜色前缀和型号后缀,提取核心材料名。"""
|
||
normalized = self._PREFIX_STRIP_RE.sub('', name).strip()
|
||
normalized = self._MODEL_SUFFIX_RE.sub('', normalized).strip()
|
||
return self._PRODUCT_ALIASES.get(normalized, normalized)
|
||
|
||
def _extract_thickness(self, text: str) -> str | None:
|
||
"""从文本中提取厚度值并归一化为无小数点字符串。
|
||
|
||
例: "0.13mm" -> "013", "0.08" -> "008"
|
||
"""
|
||
if not text:
|
||
return None
|
||
m = self._THICKNESS_RE.search(text)
|
||
if m:
|
||
return m.group(1).replace('.', '')
|
||
return None
|
||
|
||
def _extract_weight(self, text: str) -> str | None:
|
||
"""从文本中提取克重数值。
|
||
|
||
例: "145g" -> "145", "200克" -> "200"
|
||
"""
|
||
if not text:
|
||
return None
|
||
m = self._WEIGHT_RE.search(text)
|
||
if m:
|
||
return m.group(1)
|
||
return None
|
||
|
||
def _apply_rule_matching(self, input_name: str, input_spec: str,
|
||
item_remark: str, demand_spec: str,
|
||
flat_products: list[dict]) -> list[tuple[dict, float]]:
|
||
"""规则匹配层:对特定业务场景返回精确候选,避免依赖模糊匹配。
|
||
|
||
返回: [(product, score), ...] 或空列表(表示无规则命中,走通用匹配)。
|
||
"""
|
||
combined_text = f"{input_name} {input_spec} {demand_spec or ''} {item_remark or ''}"
|
||
|
||
# --- 输送带规则 ---
|
||
if any(kw in combined_text for kw in self._CONVEYOR_KEYWORDS):
|
||
is_mesh = any(kw in combined_text for kw in self._CONVEYOR_MESH_KEYWORDS)
|
||
target = "网格输送带" if is_mesh else "高温布"
|
||
hits = []
|
||
for prod in flat_products:
|
||
pname = prod["product_name"]
|
||
if target in pname or pname in target:
|
||
hits.append((prod, 0.95))
|
||
if hits:
|
||
return hits
|
||
|
||
# --- 胶带规则 ---
|
||
for kw, target in self._TAPE_EXACT_MAP.items():
|
||
if kw in combined_text:
|
||
hits = []
|
||
for prod in flat_products:
|
||
if target in prod["product_name"] or prod["product_name"] in target:
|
||
hits.append((prod, 0.95))
|
||
if hits:
|
||
return hits
|
||
if any(kw in combined_text for kw in self._TAPE_FIBER_KEYWORDS):
|
||
hits = []
|
||
for prod in flat_products:
|
||
if "特氟龙玻纤胶带" in prod["product_name"] or prod["product_name"] in "特氟龙玻纤胶带":
|
||
hits.append((prod, 0.95))
|
||
elif "胶带" in prod["product_name"] and "纯膜" not in prod["product_name"]:
|
||
hits.append((prod, 0.85))
|
||
if hits:
|
||
return hits
|
||
|
||
# --- 日用品高温布规则 ---
|
||
if any(kw in combined_text for kw in self._DAILY_CLOTH_KEYWORDS):
|
||
weight = self._extract_weight(combined_text)
|
||
hits = []
|
||
for prod in flat_products:
|
||
pname = prod["product_name"]
|
||
spec = prod.get("specification", "")
|
||
# 日用品方向产品优先
|
||
is_daily = "日用品" in pname or "日用品" in prod.get("category_name", "")
|
||
weight_match = weight and weight in spec
|
||
if is_daily:
|
||
score = 0.95 if weight_match else 0.85
|
||
hits.append((prod, score))
|
||
elif weight_match and ("高温布" in pname or "高温布" in spec):
|
||
hits.append((prod, 0.75))
|
||
if hits:
|
||
hits.sort(key=lambda x: x[1], reverse=True)
|
||
return hits
|
||
|
||
# --- 揉面垫 / 硅胶烤垫规则:厚度主导 ---
|
||
if "揉面垫" in combined_text or "硅胶烤垫" in combined_text:
|
||
thickness = self._extract_thickness(combined_text)
|
||
hits = []
|
||
for prod in flat_products:
|
||
pname = prod["product_name"]
|
||
spec = prod.get("specification", "")
|
||
if "揉面垫" in combined_text and "揉面垫" not in pname:
|
||
continue
|
||
if "硅胶烤垫" in combined_text and "硅胶烤垫" not in pname:
|
||
continue
|
||
if thickness and thickness in spec.replace('.', ''):
|
||
hits.append((prod, 0.95))
|
||
else:
|
||
hits.append((prod, 0.6))
|
||
if hits:
|
||
hits.sort(key=lambda x: x[1], reverse=True)
|
||
return hits
|
||
|
||
return []
|
||
|
||
def _score_with_thickness(self, input_name: str, input_spec: str,
|
||
prod: dict, base_score: float) -> float:
|
||
"""在基础分数上叠加厚度/克重匹配加权。"""
|
||
combined = f"{input_name} {input_spec}"
|
||
thickness = self._extract_thickness(combined)
|
||
weight = self._extract_weight(combined)
|
||
spec = prod.get("specification", "")
|
||
|
||
bonus = 0.0
|
||
if thickness and thickness in spec.replace('.', ''):
|
||
bonus = 0.3
|
||
elif weight and weight in spec:
|
||
bonus = 0.25
|
||
|
||
return min(base_score + bonus, 1.0)
|
||
|
||
def _match_products(self, items: list[dict],
|
||
product_groups: list[dict],
|
||
session: Session | None) -> list[dict]:
|
||
"""对解析出的产品明细进行库内模糊匹配。
|
||
|
||
优先走规则匹配(输送带/胶带/揉面垫等),再走通用模糊匹配,
|
||
最后 DB 兜底。
|
||
|
||
参数:
|
||
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),
|
||
"category_id": group.get("category_id"),
|
||
"category_name": group.get("category_name", ""),
|
||
})
|
||
matches: list[dict] = []
|
||
for item in items:
|
||
input_name = item.get("product_name", "")
|
||
input_spec = item.get("specification", "")
|
||
item_remark = item.get("remark", "")
|
||
demand_spec = item.get("demand_specification", "")
|
||
if not input_name:
|
||
continue
|
||
normalized_input = self._normalize_product_name(input_name)
|
||
|
||
# 1. 规则匹配层(业务场景优先)
|
||
rule_hits = self._apply_rule_matching(
|
||
input_name, input_spec, item_remark, demand_spec, flat_products
|
||
)
|
||
|
||
scored: list[tuple] = []
|
||
if rule_hits:
|
||
scored = rule_hits
|
||
else:
|
||
# 2. 通用模糊匹配
|
||
for prod in flat_products:
|
||
normalized_prod = self._normalize_product_name(prod["product_name"])
|
||
if normalized_input == normalized_prod:
|
||
base = 1.0
|
||
else:
|
||
name_score = SequenceMatcher(None, normalized_input, normalized_prod).ratio()
|
||
if normalized_prod in normalized_input or normalized_input in normalized_prod:
|
||
name_score = max(name_score, 0.85)
|
||
spec_score = SequenceMatcher(None, input_spec, prod["specification"]).ratio() if input_spec else 0
|
||
base = name_score * 0.5 + spec_score * 0.3
|
||
# 叠加厚度/克重加权
|
||
final_score = self._score_with_thickness(input_name, input_spec, prod, base)
|
||
scored.append((prod, final_score))
|
||
|
||
scored.sort(key=lambda x: x[1], reverse=True)
|
||
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.25
|
||
]
|
||
# 3. 内存匹配失败时,直接查数据库兜底
|
||
if not candidates and session is not None:
|
||
logger.info("[智能填单] 内存匹配失败, input=%s, normalized=%s, 尝试DB兜底",
|
||
input_name, normalized_input)
|
||
candidates = self._db_fallback_match(input_name, normalized_input, session)
|
||
logger.info("[智能填单] DB兜底结果: %s", [(c["product_name"], c["product_id"]) for c in candidates])
|
||
matches.append({
|
||
"input_name": input_name,
|
||
"input_specification": input_spec,
|
||
"candidates": candidates,
|
||
})
|
||
return matches
|
||
|
||
def _db_fallback_match(self, input_name: str, normalized_input: str,
|
||
session: Session) -> list[dict]:
|
||
"""内存匹配失败时,直接查 Product 表兜底。"""
|
||
from backend.app.models.business import Product
|
||
try:
|
||
# 用 LIKE 按产品名模糊搜索
|
||
stmt = select(Product).where(
|
||
Product.deleted == 0,
|
||
Product.product_name.contains(normalized_input),
|
||
).limit(5)
|
||
products = list(session.execute(stmt).scalars())
|
||
if not products:
|
||
# 试一下原始输入名(可能 LLM 返回了带型号的全名)
|
||
stmt = select(Product).where(
|
||
Product.deleted == 0,
|
||
Product.product_name.contains(input_name),
|
||
).limit(5)
|
||
products = list(session.execute(stmt).scalars())
|
||
results = []
|
||
for p in products:
|
||
results.append({
|
||
"product_id": p.id,
|
||
"product_name": p.product_name,
|
||
"specification": p.specification,
|
||
"unit": p.unit,
|
||
"sale_price": float(p.sale_price or 0),
|
||
"cost_price": float(p.cost_price or 0),
|
||
"match_score": 0.8,
|
||
})
|
||
return results[:3]
|
||
except Exception:
|
||
return []
|
||
|
||
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)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 单位标准化:统一 demand_specification 中的单位格式
|
||
# ------------------------------------------------------------------
|
||
|
||
# 长度单位映射:中文/英文 -> 标准单位
|
||
_LENGTH_UNIT_MAP = {
|
||
"cm": "cm", "厘米": "cm",
|
||
"m": "m", "米": "m",
|
||
"mm": "mm", "毫米": "mm",
|
||
}
|
||
|
||
# 重量单位映射:中文/英文 -> 标准单位
|
||
_WEIGHT_UNIT_MAP = {
|
||
"g": "g", "克": "g",
|
||
"kg": "kg", "公斤": "kg",
|
||
"jin": "斤", "斤": "斤",
|
||
}
|
||
|
||
# 长度尺寸匹配正则:数字 + 可选单位 × 数字 + 可选单位
|
||
_LENGTH_SPEC_RE = re.compile(
|
||
r'([\d.]+)\s*(cm|厘米|m|米|mm|毫米)?\s*[×xX*]\s*([\d.]+)\s*(cm|厘米|m|米|mm|毫米)?',
|
||
re.IGNORECASE
|
||
)
|
||
|
||
# 重量匹配正则:数字 + 单位
|
||
_WEIGHT_SPEC_RE = re.compile(
|
||
r'([\d.]+)\s*(g|克|kg|公斤|斤)',
|
||
re.IGNORECASE
|
||
)
|
||
|
||
def _standardize_demand_specification(self, spec: str) -> str:
|
||
"""标准化需求规格中的单位格式。
|
||
|
||
将所有长度单位统一转换为 "长m×宽m" 格式,
|
||
将所有重量单位统一转换为 "数值+标准单位" 格式。
|
||
|
||
参数:
|
||
spec: 原始需求规格字符串。
|
||
|
||
返回:
|
||
标准化后的字符串。
|
||
"""
|
||
if not spec:
|
||
return spec
|
||
|
||
# 尝试匹配长度尺寸(宽×高)
|
||
length_match = self._LENGTH_SPEC_RE.search(spec)
|
||
if length_match:
|
||
return self._standardize_length_spec(spec, length_match)
|
||
|
||
# 尝试匹配重量
|
||
weight_match = self._WEIGHT_SPEC_RE.search(spec)
|
||
if weight_match:
|
||
return self._standardize_weight_spec(spec, weight_match)
|
||
|
||
# 无法识别,返回原值
|
||
return spec
|
||
|
||
def _standardize_length_spec(self, spec: str, match: re.Match) -> str:
|
||
"""标准化长度尺寸为 "长m×宽m" 格式。"""
|
||
v1 = float(match.group(1))
|
||
u1 = self._LENGTH_UNIT_MAP.get((match.group(2) or "cm").lower(), "cm")
|
||
v2 = float(match.group(3))
|
||
u2 = self._LENGTH_UNIT_MAP.get((match.group(4) or "cm").lower(), "cm")
|
||
|
||
# 转换为米
|
||
def to_m(v, u):
|
||
if u == "m":
|
||
return v
|
||
elif u == "mm":
|
||
return v / 1000
|
||
else: # cm
|
||
return v / 100
|
||
|
||
m1 = to_m(v1, u1)
|
||
m2 = to_m(v2, u2)
|
||
|
||
# 格式化:保留必要小数位
|
||
def format_m(v):
|
||
if v == int(v):
|
||
return str(int(v))
|
||
# 去除尾部多余的0
|
||
return f"{v:.4f}".rstrip("0").rstrip(".")
|
||
|
||
return f"{format_m(m1)}m×{format_m(m2)}m"
|
||
|
||
def _standardize_weight_spec(self, spec: str, match: re.Match) -> str:
|
||
"""标准化重量单位。"""
|
||
value = float(match.group(1))
|
||
unit = self._WEIGHT_UNIT_MAP.get(match.group(2).lower(), match.group(2))
|
||
|
||
# 格式化数值
|
||
if value == int(value):
|
||
value_str = str(int(value))
|
||
else:
|
||
value_str = str(value)
|
||
|
||
return f"{value_str}{unit}"
|
||
|
||
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 []
|
||
|
||
def parse_customer(self, payload: dict, session: Session | None = None) -> dict:
|
||
"""客户信息智能解析入口:从文本中提取客户信息。
|
||
|
||
复用订单解析的文本预处理和规则提取逻辑,专门用于提取客户相关信息。
|
||
处理流程:
|
||
1. 文本预处理(去噪声)
|
||
2. 规则提取(正则提取手机号、地址)
|
||
3. LLM 结构化解析(提取客户姓名等)
|
||
4. 客户库预匹配(通过手机号查找已有客户)
|
||
|
||
参数:
|
||
payload: 请求数据字典,必含 text 字段。
|
||
session: 数据库会话,为 None 时跳过数据库查询。
|
||
|
||
返回:
|
||
包含 parsed_customer、raw_text、confidence、warnings 的字典。
|
||
|
||
被调用方:ai 路由(客户信息解析接口)。
|
||
"""
|
||
raw_text = payload.get("text", "").strip()
|
||
|
||
# 1. 文本预处理
|
||
preprocessor = TextPreprocessor()
|
||
raw_text = preprocessor.preprocess(raw_text)
|
||
if not raw_text:
|
||
raise AppException(
|
||
code=ErrorCode.PARAM_ERROR,
|
||
message="无法提取到文本内容",
|
||
status_code=400,
|
||
)
|
||
|
||
# 2. 规则提取(手机号、地址)
|
||
rule_extractor = RuleExtractor()
|
||
rule_result = rule_extractor.extract(raw_text)
|
||
|
||
# 3. LLM 结构化解析(提取客户姓名等)
|
||
settings = self.settings
|
||
llm_parsed = {}
|
||
|
||
# 构造简化的 prompt,只提取客户信息
|
||
system_prompt = """你是客户信息提取助手。从用户提供的文本中提取客户信息。
|
||
|
||
## 重要规则
|
||
1. 只提取文本中明确提到的信息,不要猜测或推断
|
||
2. 如果某个字段无法识别,返回空字符串 ""
|
||
3. customer_name 是客户姓名/称呼,不是公司名、订单号或其他编号
|
||
4. customer_mobile 必须是11位手机号码(1开头的数字)
|
||
5. customer_address 只包含地理地址信息(省/市/区/县/镇/街道/路/号等),不要包含其他内容
|
||
|
||
## 输出格式
|
||
严格按以下 JSON 格式输出,不要添加任何其他内容:
|
||
{"customer_name":"","customer_mobile":"","customer_address":"","customer_type":"","settlement_type":"","remark":""}
|
||
|
||
## 字段说明
|
||
- customer_name:客户姓名或称呼(如"张三"、"李总")
|
||
- customer_mobile:11位手机号
|
||
- customer_address:收货地址(只包含地理信息)
|
||
- customer_type:客户类型,只能是 "channel"/"retail"/"project" 之一,不确定则留空
|
||
- settlement_type:结算方式,只能是 "monthly"/"cash"/"delivered" 之一,不确定则留空
|
||
- remark:备注信息"""
|
||
|
||
payload_data = json.dumps({
|
||
"model": settings.llm_parse_model,
|
||
"messages": [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": f"请从以下文本中提取客户信息:\n\n{raw_text}"},
|
||
],
|
||
"temperature": 0.1,
|
||
"max_tokens": 512,
|
||
}).encode("utf-8")
|
||
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {settings.llm_parse_api_key or settings.aliyun_ai_access_key_id}",
|
||
}
|
||
|
||
try:
|
||
req = request.Request(
|
||
url=settings.llm_parse_api_url, data=payload_data, headers=headers, method="POST"
|
||
)
|
||
with request.urlopen(req, timeout=30) as resp:
|
||
result = json.loads(resp.read().decode("utf-8"))
|
||
|
||
content = result["choices"][0]["message"]["content"]
|
||
logger.info("[客户信息解析] LLM 返回: %s", content)
|
||
|
||
# 解析 JSON
|
||
llm_parsed = self._extract_customer_json(content)
|
||
except Exception as e:
|
||
logger.warning("[客户信息解析] LLM 解析失败: %s", str(e))
|
||
llm_parsed = {}
|
||
|
||
# 4. 合并规则提取和 LLM 解析结果
|
||
parsed_customer = {}
|
||
|
||
# 手机号:优先使用规则提取的(更可靠)
|
||
if rule_result.get("customer_mobile"):
|
||
parsed_customer["customer_mobile"] = rule_result["customer_mobile"]
|
||
elif llm_parsed.get("customer_mobile"):
|
||
parsed_customer["customer_mobile"] = llm_parsed["customer_mobile"]
|
||
|
||
# 地址:优先使用规则提取的(更可靠)
|
||
if rule_result.get("customer_address"):
|
||
parsed_customer["customer_address"] = rule_result["customer_address"]
|
||
elif llm_parsed.get("customer_address"):
|
||
parsed_customer["customer_address"] = llm_parsed["customer_address"]
|
||
|
||
# 客户姓名:使用 LLM 解析的结果
|
||
if llm_parsed.get("customer_name"):
|
||
parsed_customer["customer_name"] = llm_parsed["customer_name"]
|
||
|
||
# 其他字段:使用 LLM 解析的结果
|
||
for key in ("customer_type", "settlement_type", "remark"):
|
||
if llm_parsed.get(key):
|
||
parsed_customer[key] = llm_parsed[key]
|
||
|
||
# 5. 客户库预匹配(通过手机号查找已有客户)
|
||
warnings = []
|
||
if session and parsed_customer.get("customer_mobile"):
|
||
try:
|
||
from backend.app.repositories.customer_repository import CustomerRepository
|
||
customer_repo = CustomerRepository(session)
|
||
existing = customer_repo.find_by_mobile(parsed_customer["customer_mobile"])
|
||
if existing:
|
||
parsed_customer["existing_customer"] = {
|
||
"customer_id": existing.customer_id,
|
||
"customer_name": existing.customer_name,
|
||
"mobile": existing.mobile,
|
||
}
|
||
warnings.append(f"手机号 {parsed_customer['customer_mobile']} 已存在于客户库中({existing.customer_name})")
|
||
except Exception as e:
|
||
logger.warning("[客户信息解析] 客户库匹配失败: %s", str(e))
|
||
|
||
# 6. 计算置信度
|
||
confidence = self._calculate_customer_confidence(parsed_customer, warnings)
|
||
|
||
return {
|
||
"parsed_customer": parsed_customer,
|
||
"raw_text": raw_text,
|
||
"confidence": confidence,
|
||
"warnings": warnings,
|
||
}
|
||
|
||
def _extract_customer_json(self, text: str) -> dict:
|
||
"""从 LLM 响应中提取客户信息 JSON。
|
||
|
||
处理 LLM 可能包裹在 markdown 代码块中的情况,
|
||
以及 LLM 响应被截断导致 JSON 不完整的常见场景。
|
||
|
||
参数:
|
||
text: LLM 原始响应文本。
|
||
|
||
返回:
|
||
解析后的客户信息字典。
|
||
"""
|
||
text = text.strip()
|
||
if text.startswith("```"):
|
||
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
|
||
text = text.rsplit("```", 1)[0]
|
||
text = text.strip()
|
||
if text.startswith("json"):
|
||
text = text[4:].strip()
|
||
|
||
try:
|
||
return json.loads(text)
|
||
except json.JSONDecodeError:
|
||
# LLM 响应可能被截断,尝试截断到最后一个完整 } 修复
|
||
last_brace = text.rfind("}")
|
||
while last_brace > 0:
|
||
try:
|
||
return json.loads(text[:last_brace + 1])
|
||
except json.JSONDecodeError:
|
||
last_brace = text.rfind("}", 0, last_brace - 1)
|
||
return {}
|
||
|
||
def _calculate_customer_confidence(self, customer: dict, warnings: list) -> float:
|
||
"""计算客户信息解析的置信度。
|
||
|
||
根据识别到的字段数量和质量计算置信度分数。
|
||
|
||
参数:
|
||
customer: 解析出的客户信息字典。
|
||
warnings: 警告信息列表。
|
||
|
||
返回:
|
||
0.1 到 1.0 之间的置信度分数。
|
||
"""
|
||
base = 0.8
|
||
|
||
# 有客户姓名加分
|
||
if customer.get("customer_name"):
|
||
base += 0.1
|
||
|
||
# 有手机号加分(手机号是关键字段)
|
||
if customer.get("customer_mobile"):
|
||
base += 0.1
|
||
|
||
# 有地址加分
|
||
if customer.get("customer_address"):
|
||
base += 0.05
|
||
|
||
# 有警告扣分(如客户已存在)
|
||
base -= len(warnings) * 0.1
|
||
|
||
return round(max(base, 0.1), 2)
|
||
|
||
|
||
ai_service = AIService()
|