baseline: 批次A-D 成果 + membership 半成品(测试红)

This commit is contained in:
agent
2026-09-11 23:11:35 +08:00
commit b3f3095d53
311 changed files with 40540 additions and 0 deletions
+183
View File
@@ -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}