Files

184 lines
6.7 KiB
Python
Raw Permalink 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 开单:自然语言文本 → 结构化订单行 → 匹配商品(批次 B2)。
链路:文本 --LLM--> [{name, barcode?, qty, unit?}] --匹配--> {matched, unmatched}
匹配优先级:barcode 精确 → 商品编码精确 → 品名精确 → 品名包含 → 相似度(difflib)
**无 LLM KEY 时抛 LlmUnavailable**(视图翻译为 400 明确错误,不 500)。
"""
from __future__ import annotations
import re
from decimal import Decimal, InvalidOperation
from difflib import SequenceMatcher
from . import llm
class LlmUnavailable(Exception):
"""未配置 LLM(无 AI_API_KEY),AI 开单不可用。"""
PARSE_PROMPT = """你是进销存系统的录单助手。把下面这段人类写的订货文本,抽取成 JSON 数组。
规则:
- 每行一个对象:{"name": "商品名", "barcode": "条码或空串", "qty": 数量数字, "unit": "单位或空串"}
- 数量缺失时默认 1;"两箱"→2、"三瓶"→3 等中文数字要转成阿拉伯数字
- 只输出 JSON 数组,不要任何解释
文本:
{text}
"""
_CN_NUM = {"零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5,
"六": 6, "七": 7, "八": 8, "九": 9, "十": 10}
def _cn_to_int(token: str) -> int | None:
"""把"三"/"十二"/"两"等中文数字转 int(够用即可,不追求完整语法)。"""
if token in _CN_NUM:
return _CN_NUM[token]
if token.startswith("十") and len(token) == 2 and token[1] in _CN_NUM:
return 10 + _CN_NUM[token[1]]
if len(token) == 2 and token[0] in _CN_NUM and token[1] == "十":
return _CN_NUM[token[0]] * 10
if len(token) == 3 and token[0] in _CN_NUM and token[1] == "十" and token[2] in _CN_NUM:
return _CN_NUM[token[0]] * 10 + _CN_NUM[token[2]]
return None
def fallback_parse(text: str) -> list:
"""无 LLM 时的规则兜底:按行/逗号切分,抓 "商品名 数字单位" 模式。
仅在显式要求(allow_rule_fallback=True)时使用——默认严格走 LLM,
保证"没有 KEY 就明确报不可用"的验收标准。
"""
items = []
for raw in re.split(r"[\n\r,,;;]+", text or ""):
line = raw.strip()
if not line:
continue
m = re.search(r"(\d+(?:\.\d+)?)\s*([^\s\d]*)", line)
qty = Decimal("1")
unit = ""
name = line
if m:
qty = Decimal(m.group(1))
unit = (m.group(2) or "").strip()
name = (line[:m.start()] + line[m.end():]).strip() or line
else:
# 中文数字:三箱 / 两瓶
m2 = re.search(r"([零一二两三四五六七八九十]+)\s*([^\s\d]*)", line)
if m2:
n = _cn_to_int(m2.group(1))
if n:
qty = Decimal(n)
unit = (m2.group(2) or "").strip()
name = (line[:m2.start()] + line[m2.end():]).strip() or line
# 去掉常见量词残留
name = re.sub(r"(箱|瓶|件|个|包|袋|提|盒|罐|桶|斤|公斤|kg|Kg|KG)$", "", name).strip()
if name:
items.append({"name": name, "barcode": "", "qty": float(qty), "unit": unit})
return items
def _norm_qty(value) -> Decimal:
try:
q = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
q = Decimal("1")
return q if q > 0 else Decimal("1")
def extract_items(text: str, *, allow_rule_fallback: bool = False) -> list:
"""文本 → [{name, barcode, qty, unit}]。无 KEY 抛 LlmUnavailable。"""
if not text or not text.strip():
return []
if not llm.available():
if allow_rule_fallback:
return fallback_parse(text)
raise LlmUnavailable("未配置 AI 服务(AI_API_KEY),AI 录单暂不可用")
data = llm.extract_json(PARSE_PROMPT.format(text=text.strip()))
if not isinstance(data, list):
# LLM 偶发返回 {"items": [...]}
if isinstance(data, dict) and isinstance(data.get("items"), list):
data = data["items"]
else:
return []
items = []
for row in data:
if not isinstance(row, dict):
continue
name = str(row.get("name") or "").strip()
if not name:
continue
items.append({
"name": name,
"barcode": str(row.get("barcode") or "").strip(),
"qty": float(_norm_qty(row.get("qty"))),
"unit": str(row.get("unit") or "").strip(),
})
return items
def match_products(tenant, items: list, *, unit_resolver=None) -> dict:
"""把抽取结果匹配到商品档案。
返回 {matched: [{...item, product_id, product_code, product_name, match_by,
score, unit_id?, unit_name?, price?}], unmatched: [...]}
"""
from apps.catalog.models import Product
products = list(Product.objects.filter(tenant=tenant, is_deleted=False))
by_barcode = {p.barcode: p for p in products if p.barcode}
by_code = {p.code.lower(): p for p in products}
by_name = {p.name: p for p in products}
matched, unmatched = [], []
for item in items:
hit, how, score = None, "", 0.0
if item.get("barcode") and item["barcode"] in by_barcode:
hit, how, score = by_barcode[item["barcode"]], "barcode", 100.0
elif item["name"].lower() in by_code:
hit, how, score = by_code[item["name"].lower()], "code", 100.0
elif item["name"] in by_name:
hit, how, score = by_name[item["name"]], "name_exact", 100.0
else:
# 包含匹配(长度优先,避免短名吃掉长名)
cands = [p for p in products if item["name"] and item["name"] in p.name]
if cands:
hit = sorted(cands, key=lambda p: len(p.name))[0]
how, score = "name_contains", 85.0
else:
# 相似度兜底
best, best_score = None, 0.0
for p in products:
r = SequenceMatcher(None, item["name"], p.name).ratio()
if r > best_score:
best, best_score = p, r
if best is not None and best_score >= 0.6:
hit, how, score = best, "fuzzy", round(best_score * 100, 1)
if hit is None:
unmatched.append(item)
continue
row = {
**item,
"product_id": hit.id,
"product_code": hit.code,
"product_name": hit.name,
"match_by": how,
"score": score,
"base_price": str(hit.sale_price),
}
if unit_resolver is not None:
row.update(unit_resolver(tenant, hit, item))
matched.append(row)
return {"matched": matched, "unmatched": unmatched}