dingdanquanliucheng/backend/app/services/ai_service.py
taiyi 4c3e20a343 fix: _get_product_groups 增加 Product 表直查兜底
当 product_service.list_products 失败或返回空时,直接查 Product 表
获取产品数据,避免因 service 层异常导致产品匹配链路断裂。
同时增加关键节点的 info 级别日志便于排查。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 16:45:06 +08:00

1532 lines
62 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 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
# ---------------------------------------------------------------------------
# 智能填单:文本预处理
# ---------------------------------------------------------------------------
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+568113711561796广东省广州市白云区石井街道
必须严格按这个结构拆分,不要把左列的产品信息混入右列的客户地址中。
## 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": "厚度 颜色 标准化尺寸",
"unit": "数量单位",
"quantity": 数量数字,
"sale_price": null,
"remark": "产品级备注"
}}
]
}}
## 示例
输入: 0.13mm 咖色高温布 1*10米 SYY+568113711561796广东省 广州市 白云区 石井街道
输出: {{"customer_name":"SYY+5681","customer_mobile":"13711561796","customer_address":"广东省广州市白云区石井街道","remark":null,"items":[{{"product_name":"高温布","specification":"0.13mm 咖色 1000mm×10000mm","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 咖色 250mm×300mm","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丝布带 228mm×565mm 重叠6mm","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
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. 多订单切分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:
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 = {
"咖色": "咖啡色", "咖啡": "咖啡色",
"高温布": "高温布", "耐高温布": "高温布", "防火布": "高温布",
"网格带": "网格输送带", "输送带": "网格输送带",
"烧烤垫": "烧烤网格片",
"特氟龙胶带": "特氟龙玻纤胶带", "特氟龙": "特氟龙玻纤胶带",
}
_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+]+$')
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 _match_products(self, items: list[dict],
product_groups: list[dict],
session: Session | None) -> list[dict]:
"""对解析出的产品明细进行库内模糊匹配。
优先从 product_groups内存缓存匹配
若匹配失败或 product_groups 为空,直接查数据库兜底。
参数:
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
normalized_input = self._normalize_product_name(input_name)
# 1. 从内存 product_groups 匹配
scored: list[tuple] = []
for prod in flat_products:
normalized_prod = self._normalize_product_name(prod["product_name"])
if normalized_input == normalized_prod:
scored.append((prod, 1.0))
continue
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
combined = name_score * 0.5 + spec_score * 0.3
scored.append((prod, combined))
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
]
# 2. 内存匹配失败时,直接查数据库兜底
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)
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()