baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user