MenuPilot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- menupilot/__init__.py +3 -0
- menupilot/__main__.py +4 -0
- menupilot/agent/__init__.py +0 -0
- menupilot/agent/agent_loop.py +414 -0
- menupilot/agent/matching_engine.py +974 -0
- menupilot/agent/option_expander.py +490 -0
- menupilot/agent/orchestration.py +570 -0
- menupilot/agent/rule_engine.py +509 -0
- menupilot/agent/sandbox.py +216 -0
- menupilot/agent/schema_analyzer.py +1026 -0
- menupilot/agent/template_preprocessor.py +293 -0
- menupilot/agent/token_classifier.py +816 -0
- menupilot/agent/tools.py +365 -0
- menupilot/agent/workflow.py +1072 -0
- menupilot/cli/human_review.py +191 -0
- menupilot/cli/repl.py +821 -0
- menupilot/config.py +113 -0
- menupilot/data/__init__.py +0 -0
- menupilot/data/canonical_schema.py +135 -0
- menupilot/data/mapping_rules.yaml +387 -0
- menupilot/data/memory.py +674 -0
- menupilot/data/token_dict.py +275 -0
- menupilot/excel_io/__init__.py +0 -0
- menupilot/excel_io/excel_reader.py +552 -0
- menupilot/excel_io/excel_writer.py +413 -0
- menupilot/main.py +322 -0
- menupilot/wizard.py +86 -0
- menupilot-0.1.0.dist-info/METADATA +397 -0
- menupilot-0.1.0.dist-info/RECORD +33 -0
- menupilot-0.1.0.dist-info/WHEEL +5 -0
- menupilot-0.1.0.dist-info/entry_points.txt +2 -0
- menupilot-0.1.0.dist-info/licenses/LICENSE +21 -0
- menupilot-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Matching Engine — 商品名精确匹配 + 属性组合匹配 + 低置信度兜底。
|
|
3
|
+
纯规则引擎,不调用 LLM。位于 Rule Engine 之后,是整个工作流的最后一步。
|
|
4
|
+
|
|
5
|
+
匹配策略(按优先级):
|
|
6
|
+
1. RapidFuzz token_sort_ratio 商品名匹配(阈值 ≥ 90)
|
|
7
|
+
2. 属性组合精确匹配(规格/温度/糖度必须匹配,奶底/茶底支持通配)
|
|
8
|
+
3. Embedding 候选召回(可选,默认关闭)
|
|
9
|
+
4. 兜底:填入最佳猜测,标注 LOW_CONFIDENCE
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from rapidfuzz import fuzz
|
|
16
|
+
|
|
17
|
+
from menupilot import config
|
|
18
|
+
from menupilot.data.canonical_schema import CANONICAL_FIELDS, REQUIRED_DIMENSIONS, WILDCARD_DIMENSIONS
|
|
19
|
+
|
|
20
|
+
# ── 常量 ────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
HIGH = "HIGH"
|
|
23
|
+
LOW_CONFIDENCE = "LOW_CONFIDENCE"
|
|
24
|
+
|
|
25
|
+
MATCH_EXACT = "exact"
|
|
26
|
+
MATCH_ATTRIBUTE = "attribute_match"
|
|
27
|
+
MATCH_PRODUCT_ONLY = "product_only"
|
|
28
|
+
MATCH_BEST_GUESS = "best_guess"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _empty(val) -> bool:
|
|
32
|
+
"""判断值是否为空。"""
|
|
33
|
+
if val is None:
|
|
34
|
+
return True
|
|
35
|
+
if isinstance(val, float) and val != val: # NaN check
|
|
36
|
+
return True
|
|
37
|
+
if isinstance(val, str) and val.strip() == "":
|
|
38
|
+
return True
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _infer_failure_reason(
|
|
43
|
+
product_score: float,
|
|
44
|
+
unmatched_attrs: List[str],
|
|
45
|
+
template_row: Dict[str, Any],
|
|
46
|
+
) -> str:
|
|
47
|
+
"""从匹配结果推断 LOW_CONFIDENCE 的失败原因。
|
|
48
|
+
|
|
49
|
+
优先级:
|
|
50
|
+
1. 商品名分数低于阈值 → PRODUCT_NOT_FOUND
|
|
51
|
+
2. 属性不匹配 → 按 milk_base > size > temperature > sugar > tea_base 顺序,
|
|
52
|
+
取第一个不匹配属性,附带模板中的实际值(用于报告展示)。
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
product_score: 商品名匹配分数。
|
|
56
|
+
unmatched_attrs: 不匹配的属性列表。
|
|
57
|
+
template_row: 模板 canonical 行(用于提取不匹配属性的实际值)。
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
失败原因字符串,格式为 "REASON_CODE" 或 "REASON_CODE:extra_value"。
|
|
61
|
+
HIGH 置信度应传空字符串,调用方自行处理。
|
|
62
|
+
"""
|
|
63
|
+
threshold = config.MATCHING_CONFIG.get("product_name_threshold", 90)
|
|
64
|
+
|
|
65
|
+
# 商品名分数不足 → 未找到
|
|
66
|
+
if product_score < threshold:
|
|
67
|
+
return "PRODUCT_NOT_FOUND"
|
|
68
|
+
|
|
69
|
+
# 属性不匹配 → 按优先级取第一个
|
|
70
|
+
priority_fields = ["milk_base", "size", "temperature", "sugar", "tea_base"]
|
|
71
|
+
for field in priority_fields:
|
|
72
|
+
if field in unmatched_attrs:
|
|
73
|
+
extra = str(template_row.get(field, "") or "").strip()
|
|
74
|
+
if extra:
|
|
75
|
+
return f"{field.upper()}_NOT_FOUND:{extra}"
|
|
76
|
+
return f"{field.upper()}_NOT_FOUND"
|
|
77
|
+
|
|
78
|
+
# 兜底
|
|
79
|
+
return "UNKNOWN"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ── 商品名匹配 ────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
def _compute_product_scores(
|
|
85
|
+
template_name: str,
|
|
86
|
+
master_names: List[str],
|
|
87
|
+
) -> List[float]:
|
|
88
|
+
"""计算模板商品名与所有主数据商品名的 RapidFuzz 相似度。
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
template_name: 模板行商品名。
|
|
92
|
+
master_names: 所有主数据商品名列表。
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
与 master_names 一一对应的 token_sort_ratio 分数列表。
|
|
96
|
+
"""
|
|
97
|
+
scores = []
|
|
98
|
+
for m_name in master_names:
|
|
99
|
+
score = fuzz.token_sort_ratio(
|
|
100
|
+
str(template_name or "").strip(),
|
|
101
|
+
str(m_name or "").strip(),
|
|
102
|
+
)
|
|
103
|
+
scores.append(score)
|
|
104
|
+
return scores
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── 属性匹配 ──────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
def _attributes_match(
|
|
110
|
+
master: Dict[str, Any],
|
|
111
|
+
template: Dict[str, Any],
|
|
112
|
+
) -> Tuple[bool, List[str], List[str]]:
|
|
113
|
+
"""检查模板行与主数据行在属性维度上是否匹配。
|
|
114
|
+
|
|
115
|
+
匹配规则:
|
|
116
|
+
- 必要维度(规格/温度/糖度):master 和 template 都必须有值且精确相等。
|
|
117
|
+
- 通配维度(奶底/茶底):master 有值时必须精确匹配;master 为空则通配(跳过)。
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
master: 主数据 canonical 行。
|
|
121
|
+
template: 模板 canonical 行。
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
(is_match, matched_fields, unmatched_fields)
|
|
125
|
+
"""
|
|
126
|
+
matched = []
|
|
127
|
+
unmatched = []
|
|
128
|
+
|
|
129
|
+
for field in REQUIRED_DIMENSIONS:
|
|
130
|
+
m_val = master.get(field)
|
|
131
|
+
t_val = template.get(field)
|
|
132
|
+
if _empty(m_val) or _empty(t_val):
|
|
133
|
+
unmatched.append(field)
|
|
134
|
+
elif str(m_val).strip() == str(t_val).strip():
|
|
135
|
+
matched.append(field)
|
|
136
|
+
else:
|
|
137
|
+
unmatched.append(field)
|
|
138
|
+
|
|
139
|
+
for field in WILDCARD_DIMENSIONS:
|
|
140
|
+
m_val = master.get(field)
|
|
141
|
+
t_val = template.get(field)
|
|
142
|
+
if _empty(m_val):
|
|
143
|
+
# master 通配:无论 template 有无值都匹配
|
|
144
|
+
matched.append(f"{field}(通配)")
|
|
145
|
+
elif _empty(t_val):
|
|
146
|
+
# master 有值但 template 缺失 → 不匹配
|
|
147
|
+
unmatched.append(field)
|
|
148
|
+
elif str(m_val).strip() == str(t_val).strip():
|
|
149
|
+
matched.append(field)
|
|
150
|
+
else:
|
|
151
|
+
unmatched.append(field)
|
|
152
|
+
|
|
153
|
+
return len(unmatched) == 0, matched, unmatched
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── 匹配主流程 ────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def match_single(
|
|
159
|
+
template_row: Dict[str, Any],
|
|
160
|
+
master_rows: List[Dict[str, Any]],
|
|
161
|
+
threshold: Optional[int] = None,
|
|
162
|
+
low_threshold: Optional[int] = None,
|
|
163
|
+
) -> Dict[str, Any]:
|
|
164
|
+
"""对单条模板行执行匹配。
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
template_row: 模板 canonical 行。
|
|
168
|
+
master_rows: 所有主数据 canonical 行。
|
|
169
|
+
threshold: 商品名高置信度阈值,默认从 config 读取。
|
|
170
|
+
low_threshold: 低置信度阈值,默认从 config 读取。
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
{
|
|
174
|
+
"sop": "T240、B30/80、S4",
|
|
175
|
+
"confidence": "HIGH" | "LOW_CONFIDENCE",
|
|
176
|
+
"product_score": 95.0,
|
|
177
|
+
"match_type": "exact" | "attribute_match" | "product_only" | "best_guess",
|
|
178
|
+
"master_index": 0,
|
|
179
|
+
"matched_attributes": [...],
|
|
180
|
+
"unmatched_attributes": [...],
|
|
181
|
+
"failure_reason": "",
|
|
182
|
+
"template_product_name": "浅浅清茶",
|
|
183
|
+
}
|
|
184
|
+
"""
|
|
185
|
+
if threshold is None:
|
|
186
|
+
threshold = config.MATCHING_CONFIG["product_name_threshold"]
|
|
187
|
+
if low_threshold is None:
|
|
188
|
+
low_threshold = config.MATCHING_CONFIG["low_confidence_threshold"]
|
|
189
|
+
|
|
190
|
+
template_name = str(template_row.get("product_name", "") or "").strip()
|
|
191
|
+
|
|
192
|
+
# 快速失败:模板商品名为空
|
|
193
|
+
if not template_name:
|
|
194
|
+
return {
|
|
195
|
+
"sop": "",
|
|
196
|
+
"confidence": LOW_CONFIDENCE,
|
|
197
|
+
"product_score": 0,
|
|
198
|
+
"match_type": MATCH_BEST_GUESS,
|
|
199
|
+
"master_index": -1,
|
|
200
|
+
"matched_attributes": [],
|
|
201
|
+
"unmatched_attributes": REQUIRED_DIMENSIONS[:],
|
|
202
|
+
"failure_reason": "PRODUCT_NOT_FOUND",
|
|
203
|
+
"template_product_name": "",
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
master_names = [str(m.get("product_name", "") or "").strip() for m in master_rows]
|
|
207
|
+
scores = _compute_product_scores(template_name, master_names)
|
|
208
|
+
|
|
209
|
+
# 分离高置信度候选(≥ threshold)和低置信度候选(≥ low_threshold)
|
|
210
|
+
high_candidates = [(i, scores[i]) for i in range(len(scores)) if scores[i] >= threshold]
|
|
211
|
+
low_candidates = [
|
|
212
|
+
(i, scores[i])
|
|
213
|
+
for i in range(len(scores))
|
|
214
|
+
if low_threshold <= scores[i] < threshold
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
def _make_result(master_idx, score, mtype, confidence, matched, unmatched,
|
|
218
|
+
failure_reason=None):
|
|
219
|
+
sop = ""
|
|
220
|
+
if 0 <= master_idx < len(master_rows):
|
|
221
|
+
sop = str(master_rows[master_idx].get("sop", "") or "")
|
|
222
|
+
# 自动推断 failure_reason(仅 LOW_CONFIDENCE)
|
|
223
|
+
if failure_reason is None and confidence == LOW_CONFIDENCE:
|
|
224
|
+
failure_reason = _infer_failure_reason(score, unmatched, template_row)
|
|
225
|
+
return {
|
|
226
|
+
"sop": sop,
|
|
227
|
+
"confidence": confidence,
|
|
228
|
+
"product_score": score,
|
|
229
|
+
"match_type": mtype,
|
|
230
|
+
"master_index": master_idx,
|
|
231
|
+
"matched_attributes": matched,
|
|
232
|
+
"unmatched_attributes": unmatched,
|
|
233
|
+
"failure_reason": failure_reason or "",
|
|
234
|
+
"template_product_name": template_name,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# ── Step 1: 高置信度候选 + 属性过滤 ──
|
|
238
|
+
if high_candidates:
|
|
239
|
+
# 按商品名分数降序排列
|
|
240
|
+
high_candidates.sort(key=lambda x: x[1], reverse=True)
|
|
241
|
+
|
|
242
|
+
attribute_matches = []
|
|
243
|
+
for idx, score in high_candidates:
|
|
244
|
+
is_match, matched_attrs, unmatched_attrs = _attributes_match(
|
|
245
|
+
master_rows[idx], template_row
|
|
246
|
+
)
|
|
247
|
+
if is_match:
|
|
248
|
+
attribute_matches.append((idx, score, matched_attrs, unmatched_attrs))
|
|
249
|
+
|
|
250
|
+
if len(attribute_matches) == 1:
|
|
251
|
+
idx, score, matched_attrs, unmatched_attrs = attribute_matches[0]
|
|
252
|
+
return _make_result(
|
|
253
|
+
idx, score, MATCH_EXACT, HIGH, matched_attrs, unmatched_attrs
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if len(attribute_matches) > 1:
|
|
257
|
+
# 多个候选都匹配属性 → 取商品名分数最高的
|
|
258
|
+
attribute_matches.sort(key=lambda x: x[1], reverse=True)
|
|
259
|
+
idx, score, matched_attrs, unmatched_attrs = attribute_matches[0]
|
|
260
|
+
return _make_result(
|
|
261
|
+
idx, score, MATCH_EXACT, HIGH, matched_attrs, unmatched_attrs
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# 无属性完全匹配 → 用商品名最接近的候选,标 LOW_CONFIDENCE
|
|
265
|
+
best_idx, best_score = high_candidates[0]
|
|
266
|
+
_, matched_attrs, unmatched_attrs = _attributes_match(
|
|
267
|
+
master_rows[best_idx], template_row
|
|
268
|
+
)
|
|
269
|
+
return _make_result(
|
|
270
|
+
best_idx,
|
|
271
|
+
best_score,
|
|
272
|
+
MATCH_PRODUCT_ONLY,
|
|
273
|
+
LOW_CONFIDENCE,
|
|
274
|
+
matched_attrs,
|
|
275
|
+
unmatched_attrs,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# ── Step 2: 低置信度候选(商品名阈值以下但仍可猜测)──
|
|
279
|
+
if low_candidates:
|
|
280
|
+
low_candidates.sort(key=lambda x: x[1], reverse=True)
|
|
281
|
+
# 属性过滤
|
|
282
|
+
for idx, score in low_candidates:
|
|
283
|
+
is_match, matched_attrs, unmatched_attrs = _attributes_match(
|
|
284
|
+
master_rows[idx], template_row
|
|
285
|
+
)
|
|
286
|
+
if is_match:
|
|
287
|
+
return _make_result(
|
|
288
|
+
idx,
|
|
289
|
+
score,
|
|
290
|
+
MATCH_PRODUCT_ONLY,
|
|
291
|
+
LOW_CONFIDENCE,
|
|
292
|
+
matched_attrs,
|
|
293
|
+
unmatched_attrs,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# 都不匹配 → 用分数最高的
|
|
297
|
+
best_idx, best_score = low_candidates[0]
|
|
298
|
+
_, matched_attrs, unmatched_attrs = _attributes_match(
|
|
299
|
+
master_rows[best_idx], template_row
|
|
300
|
+
)
|
|
301
|
+
return _make_result(
|
|
302
|
+
best_idx,
|
|
303
|
+
best_score,
|
|
304
|
+
MATCH_BEST_GUESS,
|
|
305
|
+
LOW_CONFIDENCE,
|
|
306
|
+
matched_attrs,
|
|
307
|
+
unmatched_attrs,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# ── Step 3: 完全无法匹配 → 全局最接近猜测 ──
|
|
311
|
+
if scores:
|
|
312
|
+
best_idx = max(range(len(scores)), key=lambda i: scores[i])
|
|
313
|
+
best_score = scores[best_idx]
|
|
314
|
+
else:
|
|
315
|
+
best_idx, best_score = -1, 0
|
|
316
|
+
|
|
317
|
+
_, matched_attrs, unmatched_attrs = (
|
|
318
|
+
_attributes_match(master_rows[best_idx], template_row)
|
|
319
|
+
if best_idx >= 0
|
|
320
|
+
else (False, [], REQUIRED_DIMENSIONS[:])
|
|
321
|
+
)
|
|
322
|
+
return _make_result(
|
|
323
|
+
best_idx,
|
|
324
|
+
best_score,
|
|
325
|
+
MATCH_BEST_GUESS,
|
|
326
|
+
LOW_CONFIDENCE,
|
|
327
|
+
matched_attrs,
|
|
328
|
+
unmatched_attrs,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def match(
|
|
333
|
+
template_rows: List[Dict[str, Any]],
|
|
334
|
+
master_rows: List[Dict[str, Any]],
|
|
335
|
+
threshold: Optional[int] = None,
|
|
336
|
+
low_threshold: Optional[int] = None,
|
|
337
|
+
) -> List[Dict[str, Any]]:
|
|
338
|
+
"""批量匹配:每条模板行匹配一条最佳主数据行。
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
template_rows: 模板 canonical 行列表。
|
|
342
|
+
master_rows: 主数据 canonical 行列表。
|
|
343
|
+
threshold: 商品名高置信度阈值。
|
|
344
|
+
low_threshold: 低置信度兜底阈值。
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
匹配结果列表,与 template_rows 一一对应。每条结果包含
|
|
348
|
+
sop / confidence / product_score / match_type / master_index / matched_attributes / unmatched_attributes。
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
ValueError: master_rows 为空。
|
|
352
|
+
"""
|
|
353
|
+
if not master_rows:
|
|
354
|
+
raise ValueError("主数据行列表不能为空")
|
|
355
|
+
if not template_rows:
|
|
356
|
+
return []
|
|
357
|
+
|
|
358
|
+
results = []
|
|
359
|
+
for t_row in template_rows:
|
|
360
|
+
result = match_single(t_row, master_rows, threshold, low_threshold)
|
|
361
|
+
results.append(result)
|
|
362
|
+
return results
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# ── 报告生成 ──────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
# ── 失败原因中文映射 ──────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
_REASON_CN_MAP = {
|
|
370
|
+
"MILK_BASE_NOT_FOUND": "{extra}规格在主数据中缺失",
|
|
371
|
+
"PRODUCT_NOT_FOUND": "商品名称在主数据中未找到",
|
|
372
|
+
"SIZE_NOT_FOUND": "规格在主数据中缺失",
|
|
373
|
+
"TEMPERATURE_NOT_FOUND": "温度/做法在主数据中缺失",
|
|
374
|
+
"SUGAR_NOT_FOUND": "糖度在主数据中缺失",
|
|
375
|
+
"TEA_BASE_NOT_FOUND": "茶底在主数据中缺失",
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
_REASON_SUGGESTION_MAP = {
|
|
379
|
+
"MILK_BASE_NOT_FOUND": "补充 {extra} 相关 SOP 到主数据表",
|
|
380
|
+
"PRODUCT_NOT_FOUND": "检查商品名称是否有错别字,或补充主数据表",
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _parse_failure_reason(reason: str) -> Tuple[str, str]:
|
|
385
|
+
"""解析 failure_reason,分离枚举码和附加值。
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
reason: 格式为 "CODE" 或 "CODE:extra" 的失败原因字符串。
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
(code, extra) 元组。extra 为空字符串表示无附加值。
|
|
392
|
+
"""
|
|
393
|
+
if ":" in reason:
|
|
394
|
+
code, extra = reason.split(":", 1)
|
|
395
|
+
return code, extra
|
|
396
|
+
return reason, ""
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _format_top_reason(reason: str, count: int) -> Tuple[str, str]:
|
|
400
|
+
"""格式化主要原因的中文描述和建议。
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
reason: failure_reason 原始值(如 "MILK_BASE_NOT_FOUND:燕麦奶")。
|
|
404
|
+
count: 该原因出现的行数。
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
(display, suggestion) 元组。display 为 "原因描述(N 行)",
|
|
408
|
+
suggestion 为建议文本,无建议时为空字符串。
|
|
409
|
+
"""
|
|
410
|
+
code, extra = _parse_failure_reason(reason)
|
|
411
|
+
|
|
412
|
+
template = _REASON_CN_MAP.get(code)
|
|
413
|
+
if template is None:
|
|
414
|
+
# 未知原因 → 直接输出原始值
|
|
415
|
+
display = f"{reason}({count} 行)"
|
|
416
|
+
return display, ""
|
|
417
|
+
|
|
418
|
+
display = template.format(extra=extra) if "{extra}" in template else template
|
|
419
|
+
display = f"{display}({count} 行)"
|
|
420
|
+
|
|
421
|
+
suggestion_tpl = _REASON_SUGGESTION_MAP.get(code, "")
|
|
422
|
+
suggestion = suggestion_tpl.format(extra=extra) if suggestion_tpl and "{extra}" in suggestion_tpl else suggestion_tpl
|
|
423
|
+
|
|
424
|
+
return display, suggestion
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def generate_report(
|
|
428
|
+
match_results: List[Dict[str, Any]],
|
|
429
|
+
) -> str:
|
|
430
|
+
"""生成面向用户的匹配摘要报告。
|
|
431
|
+
|
|
432
|
+
输出中文分级的摘要,展示高置信度/需要确认/完全失败的行数,
|
|
433
|
+
并自动聚合最常见失败原因及建议。摘要下方附详细日志供调试。
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
match_results: match() 返回的结果列表。
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
格式化的报告文本(用于终端打印和文件写入)。
|
|
440
|
+
"""
|
|
441
|
+
total = len(match_results)
|
|
442
|
+
high = sum(1 for r in match_results if r.get("confidence") == HIGH)
|
|
443
|
+
low = sum(1 for r in match_results if r.get("confidence") == LOW_CONFIDENCE)
|
|
444
|
+
failed = total - high - low
|
|
445
|
+
|
|
446
|
+
lines = []
|
|
447
|
+
# ── 摘要部分 ──
|
|
448
|
+
lines.append("=" * 56)
|
|
449
|
+
lines.append(f"本次映射完成,共 {total} 行")
|
|
450
|
+
lines.append("")
|
|
451
|
+
|
|
452
|
+
# ✅ 高置信匹配
|
|
453
|
+
lines.append(f"✅ 高置信匹配:{high} 行")
|
|
454
|
+
|
|
455
|
+
# ⚠️ 需要确认
|
|
456
|
+
if low > 0:
|
|
457
|
+
lines.append(f"⚠️ 需要确认:{low} 行")
|
|
458
|
+
|
|
459
|
+
# 聚合 failure_reason
|
|
460
|
+
reason_counter: Counter = Counter()
|
|
461
|
+
for r in match_results:
|
|
462
|
+
if r.get("confidence") == LOW_CONFIDENCE:
|
|
463
|
+
reason = r.get("failure_reason", "UNKNOWN")
|
|
464
|
+
reason_counter[reason] += 1
|
|
465
|
+
|
|
466
|
+
if reason_counter:
|
|
467
|
+
top_reason, top_count = reason_counter.most_common(1)[0]
|
|
468
|
+
display, suggestion = _format_top_reason(top_reason, top_count)
|
|
469
|
+
lines.append(f" └─ 主要原因:{display}")
|
|
470
|
+
if suggestion:
|
|
471
|
+
lines.append(f" 建议:{suggestion}")
|
|
472
|
+
|
|
473
|
+
# ❌ 完全失败
|
|
474
|
+
if failed > 0:
|
|
475
|
+
lines.append(f"❌ 完全失败:{failed} 行")
|
|
476
|
+
else:
|
|
477
|
+
lines.append("❌ 完全失败:0 行")
|
|
478
|
+
|
|
479
|
+
lines.append("=" * 56)
|
|
480
|
+
|
|
481
|
+
# ── 详细日志部分 ──
|
|
482
|
+
if low > 0:
|
|
483
|
+
lines.append("")
|
|
484
|
+
lines.append("--- 详细日志 ---")
|
|
485
|
+
lines.append("")
|
|
486
|
+
for i, r in enumerate(match_results):
|
|
487
|
+
if r.get("confidence") == LOW_CONFIDENCE:
|
|
488
|
+
score = r.get("product_score", 0)
|
|
489
|
+
mtype = r.get("match_type", "?")
|
|
490
|
+
reason = r.get("failure_reason", "?")
|
|
491
|
+
unmatched = r.get("unmatched_attributes", [])
|
|
492
|
+
lines.append(
|
|
493
|
+
f" 行 {i + 1}: "
|
|
494
|
+
f"商品名分数={score:.1f}, "
|
|
495
|
+
f"匹配类型={mtype}, "
|
|
496
|
+
f"原因={reason}"
|
|
497
|
+
)
|
|
498
|
+
if unmatched:
|
|
499
|
+
lines.append(f" 不匹配属性: {', '.join(unmatched)}")
|
|
500
|
+
|
|
501
|
+
return "\n".join(lines)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _format_single_reason(reason: str) -> str:
|
|
505
|
+
"""将单个 failure_reason 转为简短中文描述。
|
|
506
|
+
|
|
507
|
+
MILK_BASE_NOT_FOUND:燕麦奶 → 主数据中没有奶底「燕麦奶」
|
|
508
|
+
SIZE_NOT_FOUND:果蔬瓶 → 主数据中没有规格「果蔬瓶」
|
|
509
|
+
PRODUCT_NOT_FOUND → 商品名在主数据中未找到
|
|
510
|
+
"""
|
|
511
|
+
code, extra = _parse_failure_reason(reason)
|
|
512
|
+
if code == "MILK_BASE_NOT_FOUND":
|
|
513
|
+
return f"主数据中没有奶底「{extra}」" if extra else "奶底在主数据中缺失"
|
|
514
|
+
if code == "SIZE_NOT_FOUND":
|
|
515
|
+
return f"主数据中没有规格「{extra}」" if extra else "规格在主数据中缺失"
|
|
516
|
+
if code == "TEMPERATURE_NOT_FOUND":
|
|
517
|
+
return f"主数据中没有温度「{extra}」" if extra else "温度/做法在主数据中缺失"
|
|
518
|
+
if code == "SUGAR_NOT_FOUND":
|
|
519
|
+
return f"主数据中没有糖度「{extra}」" if extra else "糖度在主数据中缺失"
|
|
520
|
+
if code == "TEA_BASE_NOT_FOUND":
|
|
521
|
+
return f"主数据中没有茶底「{extra}」" if extra else "茶底在主数据中缺失"
|
|
522
|
+
if code == "PRODUCT_NOT_FOUND":
|
|
523
|
+
return "商品名在主数据中未找到"
|
|
524
|
+
return reason
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def generate_console_summary(
|
|
528
|
+
match_results: List[Dict[str, Any]],
|
|
529
|
+
report_path: str = "",
|
|
530
|
+
) -> str:
|
|
531
|
+
"""生成面向控制台的摘要报告(无详细日志)。
|
|
532
|
+
|
|
533
|
+
按产品分组,同一产品的多个原因合并显示。末尾提示报告文件路径。
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
match_results: match() 返回的结果列表。
|
|
537
|
+
report_path: 详细报告文件路径(用于末尾提示)。
|
|
538
|
+
|
|
539
|
+
Returns:
|
|
540
|
+
格式化的控制台报告文本。
|
|
541
|
+
"""
|
|
542
|
+
total = len(match_results)
|
|
543
|
+
high = sum(1 for r in match_results if r.get("confidence") == HIGH)
|
|
544
|
+
low = sum(1 for r in match_results if r.get("confidence") == LOW_CONFIDENCE)
|
|
545
|
+
failed = total - high - low
|
|
546
|
+
high_pct = (100 * high / total) if total > 0 else 0
|
|
547
|
+
low_pct = (100 * low / total) if total > 0 else 0
|
|
548
|
+
|
|
549
|
+
lines = []
|
|
550
|
+
lines.append("=" * 56)
|
|
551
|
+
lines.append(f"本次映射完成,共 {total} 行")
|
|
552
|
+
lines.append("")
|
|
553
|
+
lines.append(f"✅ 高置信匹配:{high} 行 ({high_pct:.1f}%)")
|
|
554
|
+
if low > 0:
|
|
555
|
+
lines.append(f"⚠️ 需要确认:{low} 行 ({low_pct:.1f}%)")
|
|
556
|
+
if failed > 0:
|
|
557
|
+
lines.append(f"❌ 完全失败:{failed} 行")
|
|
558
|
+
else:
|
|
559
|
+
lines.append("❌ 完全失败:0 行")
|
|
560
|
+
lines.append("=" * 56)
|
|
561
|
+
|
|
562
|
+
if low == 0:
|
|
563
|
+
if report_path:
|
|
564
|
+
lines.append(f"\n详细报告已存储在: {report_path}")
|
|
565
|
+
return "\n".join(lines)
|
|
566
|
+
|
|
567
|
+
# ── 按产品分组聚合原因 ──
|
|
568
|
+
from collections import defaultdict
|
|
569
|
+
product_reasons: Dict[str, List[str]] = defaultdict(list)
|
|
570
|
+
product_counts: Dict[str, int] = defaultdict(int)
|
|
571
|
+
|
|
572
|
+
for r in match_results:
|
|
573
|
+
if r.get("confidence") != LOW_CONFIDENCE:
|
|
574
|
+
continue
|
|
575
|
+
product = str(r.get("template_product_name", "") or "").strip()
|
|
576
|
+
if not product:
|
|
577
|
+
product = "(空商品名)"
|
|
578
|
+
reason = r.get("failure_reason", "UNKNOWN")
|
|
579
|
+
product_counts[product] += 1
|
|
580
|
+
if reason not in product_reasons[product]:
|
|
581
|
+
product_reasons[product].append(reason)
|
|
582
|
+
|
|
583
|
+
# ── 生成表格 ──
|
|
584
|
+
# 表头
|
|
585
|
+
lines.append("")
|
|
586
|
+
lines.append("低置信度明细:")
|
|
587
|
+
header = f" {'产品':<20} {'原因':<50} {'行数':<6}"
|
|
588
|
+
lines.append(header)
|
|
589
|
+
lines.append(f" {'-'*18} {'-'*48} {'-'*4}")
|
|
590
|
+
|
|
591
|
+
# 按行数降序排列
|
|
592
|
+
sorted_products = sorted(product_counts.items(), key=lambda x: x[1], reverse=True)
|
|
593
|
+
for product, count in sorted_products:
|
|
594
|
+
reasons = product_reasons[product]
|
|
595
|
+
reason_texts = [_format_single_reason(r) for r in reasons]
|
|
596
|
+
combined = "、".join(reason_texts)
|
|
597
|
+
|
|
598
|
+
# 产品名截断
|
|
599
|
+
display_name = product if len(product) <= 20 else product[:17] + "..."
|
|
600
|
+
|
|
601
|
+
# 原因文本折行处理
|
|
602
|
+
max_reason_len = 48
|
|
603
|
+
if len(combined) <= max_reason_len:
|
|
604
|
+
lines.append(f" {display_name:<20} {combined:<50} {count}")
|
|
605
|
+
else:
|
|
606
|
+
# 第一行
|
|
607
|
+
first_line = combined[:max_reason_len]
|
|
608
|
+
lines.append(f" {display_name:<20} {first_line:<50} {count}")
|
|
609
|
+
# 续行
|
|
610
|
+
remaining = combined[max_reason_len:]
|
|
611
|
+
while remaining:
|
|
612
|
+
chunk = remaining[:max_reason_len]
|
|
613
|
+
lines.append(f" {'':<20} {chunk:<48}")
|
|
614
|
+
remaining = remaining[max_reason_len:]
|
|
615
|
+
|
|
616
|
+
# ── 末尾:报告路径提示 ──
|
|
617
|
+
if report_path:
|
|
618
|
+
lines.append("")
|
|
619
|
+
lines.append(f"详细报告已存储在: {report_path}")
|
|
620
|
+
|
|
621
|
+
return "\n".join(lines)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# ── Embedding 兜底(可选)──────────────────────────────────────
|
|
625
|
+
|
|
626
|
+
def build_embedding_index(master_rows: List[Dict[str, Any]]) -> Optional[Any]:
|
|
627
|
+
"""构建主数据商品名的 FAISS 向量索引。
|
|
628
|
+
|
|
629
|
+
仅在 config.MATCHING_CONFIG['embedding_enabled'] = True 时可用。
|
|
630
|
+
sentence-transformers 和 faiss 为可选依赖,运行时按需导入。
|
|
631
|
+
|
|
632
|
+
Args:
|
|
633
|
+
master_rows: 主数据 canonical 行列表。
|
|
634
|
+
|
|
635
|
+
Returns:
|
|
636
|
+
(model, index, product_names) 元组;失败返回 None。
|
|
637
|
+
"""
|
|
638
|
+
if not config.MATCHING_CONFIG["embedding_enabled"]:
|
|
639
|
+
return None
|
|
640
|
+
|
|
641
|
+
try:
|
|
642
|
+
from sentence_transformers import SentenceTransformer
|
|
643
|
+
import faiss
|
|
644
|
+
import numpy as np
|
|
645
|
+
except ImportError as e:
|
|
646
|
+
print(f"[WARNING] Embedding 兜底依赖缺失: {e}")
|
|
647
|
+
return None
|
|
648
|
+
|
|
649
|
+
model_name = config.MATCHING_CONFIG["embedding_model"]
|
|
650
|
+
model = SentenceTransformer(model_name)
|
|
651
|
+
names = [str(m.get("product_name", "")) for m in master_rows]
|
|
652
|
+
embeddings = model.encode(names, convert_to_numpy=True)
|
|
653
|
+
|
|
654
|
+
dim = embeddings.shape[1]
|
|
655
|
+
index = faiss.IndexFlatIP(dim)
|
|
656
|
+
# FAISS IndexFlatIP 需要归一化向量 -> cosine similarity
|
|
657
|
+
faiss.normalize_L2(embeddings)
|
|
658
|
+
index.add(embeddings)
|
|
659
|
+
|
|
660
|
+
return (model, index, names)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def embedding_recall(
|
|
664
|
+
template_name: str,
|
|
665
|
+
embedding_index: Any,
|
|
666
|
+
top_k: Optional[int] = None,
|
|
667
|
+
sim_threshold: Optional[float] = None,
|
|
668
|
+
) -> List[Tuple[int, float]]:
|
|
669
|
+
"""用 Embedding 召回候选商品名。
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
template_name: 模板商品名。
|
|
673
|
+
embedding_index: build_embedding_index() 返回的 (model, index, names) 元组。
|
|
674
|
+
top_k: 返回候选数。
|
|
675
|
+
sim_threshold: 相似度阈值。
|
|
676
|
+
|
|
677
|
+
Returns:
|
|
678
|
+
[(master_index, similarity_score), ...] 按相似度降序排列。
|
|
679
|
+
"""
|
|
680
|
+
if embedding_index is None:
|
|
681
|
+
return []
|
|
682
|
+
|
|
683
|
+
if top_k is None:
|
|
684
|
+
top_k = config.MATCHING_CONFIG["embedding_top_k"]
|
|
685
|
+
if sim_threshold is None:
|
|
686
|
+
sim_threshold = config.MATCHING_CONFIG["embedding_similarity_threshold"]
|
|
687
|
+
|
|
688
|
+
import numpy as np
|
|
689
|
+
|
|
690
|
+
model, index, names = embedding_index
|
|
691
|
+
query_vec = model.encode([template_name], convert_to_numpy=True)
|
|
692
|
+
faiss.normalize_L2(query_vec)
|
|
693
|
+
|
|
694
|
+
scores, indices = index.search(query_vec, min(top_k, len(names)))
|
|
695
|
+
|
|
696
|
+
results = []
|
|
697
|
+
for score, idx in zip(scores[0], indices[0]):
|
|
698
|
+
if idx < 0 or idx >= len(names):
|
|
699
|
+
continue
|
|
700
|
+
if score >= sim_threshold:
|
|
701
|
+
results.append((int(idx), float(score)))
|
|
702
|
+
|
|
703
|
+
return results
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
# ── 自测 ──────────────────────────────────────────────────────
|
|
707
|
+
|
|
708
|
+
if __name__ == "__main__":
|
|
709
|
+
passed = 0
|
|
710
|
+
failed = 0
|
|
711
|
+
|
|
712
|
+
def check(condition, msg):
|
|
713
|
+
global passed, failed
|
|
714
|
+
if condition:
|
|
715
|
+
passed += 1
|
|
716
|
+
print(f" PASS {msg}")
|
|
717
|
+
else:
|
|
718
|
+
failed += 1
|
|
719
|
+
print(f" FAIL {msg}")
|
|
720
|
+
|
|
721
|
+
print("=== Matching Engine 自测 ===\n")
|
|
722
|
+
|
|
723
|
+
# ── 准备测试用主数据 ──
|
|
724
|
+
master = [
|
|
725
|
+
{
|
|
726
|
+
"product_name": "浅浅清茶",
|
|
727
|
+
"size": "中杯",
|
|
728
|
+
"milk_base": "牛奶",
|
|
729
|
+
"temperature": "少冰",
|
|
730
|
+
"sugar": "七分糖",
|
|
731
|
+
"tea_base": None,
|
|
732
|
+
"sop": "T240、B30/80、S4、IC(S)、MS 3-5",
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
"product_name": "浅浅清茶",
|
|
736
|
+
"size": "中杯",
|
|
737
|
+
"milk_base": "牛奶",
|
|
738
|
+
"temperature": "去冰",
|
|
739
|
+
"sugar": "标准糖",
|
|
740
|
+
"tea_base": None,
|
|
741
|
+
"sop": "T265、B30/105、S5、IC(S)、MS 3-5",
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
"product_name": "浅浅清茶",
|
|
745
|
+
"size": "大杯",
|
|
746
|
+
"milk_base": "牛奶",
|
|
747
|
+
"temperature": "正常冰",
|
|
748
|
+
"sugar": "全糖",
|
|
749
|
+
"tea_base": None,
|
|
750
|
+
"sop": "T300、B40/120、S6、IC(S)、MS 3-5",
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
"product_name": "黑糖波波牛乳",
|
|
754
|
+
"size": "大杯",
|
|
755
|
+
"milk_base": None, # 通配:黑糖波波牛乳不挑奶底
|
|
756
|
+
"temperature": "正常冰",
|
|
757
|
+
"sugar": "标准糖",
|
|
758
|
+
"tea_base": None,
|
|
759
|
+
"sop": "T200、B50/100、S5、MS 3-5",
|
|
760
|
+
},
|
|
761
|
+
{
|
|
762
|
+
"product_name": "珍珠奶茶",
|
|
763
|
+
"size": "中杯",
|
|
764
|
+
"milk_base": "椰乳",
|
|
765
|
+
"temperature": "热",
|
|
766
|
+
"sugar": "无糖",
|
|
767
|
+
"tea_base": None,
|
|
768
|
+
"sop": "T180、B40/80、S2、HOT、MS 2-3",
|
|
769
|
+
},
|
|
770
|
+
]
|
|
771
|
+
|
|
772
|
+
# ── 1. 精确匹配:商品名 + 所有属性匹配 ──
|
|
773
|
+
print("1. 精确匹配(商品名 + 全属性匹配)")
|
|
774
|
+
t1 = {
|
|
775
|
+
"product_name": "浅浅清茶",
|
|
776
|
+
"size": "中杯",
|
|
777
|
+
"milk_base": "牛奶",
|
|
778
|
+
"temperature": "少冰",
|
|
779
|
+
"sugar": "七分糖",
|
|
780
|
+
"tea_base": None,
|
|
781
|
+
}
|
|
782
|
+
r1 = match_single(t1, master)
|
|
783
|
+
check(r1["confidence"] == HIGH, f"置信度 HIGH(实际 {r1['confidence']})")
|
|
784
|
+
check(r1["match_type"] == MATCH_EXACT, f"匹配类型 exact(实际 {r1['match_type']})")
|
|
785
|
+
check(r1["product_score"] >= 90, f"商品名分数 ≥ 90(实际 {r1['product_score']})")
|
|
786
|
+
check(r1["sop"] == "T240、B30/80、S4、IC(S)、MS 3-5", f"SOP 正确")
|
|
787
|
+
check(
|
|
788
|
+
set(r1["unmatched_attributes"]) == set(),
|
|
789
|
+
f"无不匹配属性(实际 {r1['unmatched_attributes']})",
|
|
790
|
+
)
|
|
791
|
+
print()
|
|
792
|
+
|
|
793
|
+
# ── 2. 精确匹配:选择不同属性的行 ──
|
|
794
|
+
print("2. 精确匹配(同名不同属性 → 选正确的)")
|
|
795
|
+
t2 = {
|
|
796
|
+
"product_name": "浅浅清茶",
|
|
797
|
+
"size": "中杯",
|
|
798
|
+
"milk_base": "牛奶",
|
|
799
|
+
"temperature": "去冰",
|
|
800
|
+
"sugar": "标准糖",
|
|
801
|
+
"tea_base": None,
|
|
802
|
+
}
|
|
803
|
+
r2 = match_single(t2, master)
|
|
804
|
+
check(r2["confidence"] == HIGH, "置信度 HIGH")
|
|
805
|
+
check(r2["sop"] == "T265、B30/105、S5、IC(S)、MS 3-5", "SOP 正确(去冰/标准糖)")
|
|
806
|
+
print()
|
|
807
|
+
|
|
808
|
+
# ── 3. 通配奶底:master 奶底为 None → 任意模板奶底都匹配 ──
|
|
809
|
+
print("3. 通配奶底匹配")
|
|
810
|
+
t3 = {
|
|
811
|
+
"product_name": "黑糖波波牛乳",
|
|
812
|
+
"size": "大杯",
|
|
813
|
+
"milk_base": "燕麦奶", # master 奶底是 None(通配),任何奶底都接受
|
|
814
|
+
"temperature": "正常冰",
|
|
815
|
+
"sugar": "标准糖",
|
|
816
|
+
"tea_base": None,
|
|
817
|
+
}
|
|
818
|
+
r3 = match_single(t3, master)
|
|
819
|
+
check(r3["confidence"] == HIGH, "置信度 HIGH(通配生效)")
|
|
820
|
+
check(r3["sop"] == "T200、B50/100、S5、MS 3-5", "SOP 正确")
|
|
821
|
+
check("milk_base(通配)" in r3["matched_attributes"], "milk_base 标记为通配匹配")
|
|
822
|
+
print()
|
|
823
|
+
|
|
824
|
+
# ── 4. 商品名高相似度匹配(token_sort_ratio ≥ 90) ──
|
|
825
|
+
print("4. 商品名高相似度匹配")
|
|
826
|
+
# 插入一个高相似度主数据行用于测试 token_sort_ratio 行为
|
|
827
|
+
master_fuzzy = master + [
|
|
828
|
+
{
|
|
829
|
+
"product_name": "黑糖波波牛乳茶",
|
|
830
|
+
"size": "中杯",
|
|
831
|
+
"milk_base": "燕麦奶",
|
|
832
|
+
"temperature": "少冰",
|
|
833
|
+
"sugar": "五分糖",
|
|
834
|
+
"tea_base": None,
|
|
835
|
+
"sop": "T999",
|
|
836
|
+
}
|
|
837
|
+
]
|
|
838
|
+
t4 = {
|
|
839
|
+
"product_name": "黑糖波波牛乳", # 缺少"茶",与"黑糖波波牛乳茶"高度相似
|
|
840
|
+
"size": "中杯",
|
|
841
|
+
"milk_base": "燕麦奶",
|
|
842
|
+
"temperature": "少冰",
|
|
843
|
+
"sugar": "五分糖",
|
|
844
|
+
"tea_base": None,
|
|
845
|
+
}
|
|
846
|
+
r4 = match_single(t4, master_fuzzy)
|
|
847
|
+
check(r4["product_score"] >= 85, f"token_sort_ratio ≥ 85(实际 {r4['product_score']:.1f})")
|
|
848
|
+
# 注意:如果分数 < 90 则是 LOW_CONFIDENCE,否则 HIGH
|
|
849
|
+
print()
|
|
850
|
+
|
|
851
|
+
# ── 4b. 完全相同的商品名 → 100 分 ──
|
|
852
|
+
print("4b. 完全相同商品名(100 分)")
|
|
853
|
+
t4b = {
|
|
854
|
+
"product_name": "浅浅清茶",
|
|
855
|
+
"size": "中杯",
|
|
856
|
+
"milk_base": "牛奶",
|
|
857
|
+
"temperature": "少冰",
|
|
858
|
+
"sugar": "七分糖",
|
|
859
|
+
"tea_base": None,
|
|
860
|
+
}
|
|
861
|
+
r4b = match_single(t4b, master)
|
|
862
|
+
check(r4b["product_score"] == 100.0, f"相同商品名 = 100(实际 {r4b['product_score']})")
|
|
863
|
+
check(r4b["confidence"] == HIGH, "置信度 HIGH")
|
|
864
|
+
print()
|
|
865
|
+
|
|
866
|
+
# ── 5. 商品名无匹配 → LOW_CONFIDENCE ──
|
|
867
|
+
print("5. 商品名无匹配(best_guess 兜底)")
|
|
868
|
+
t5 = {
|
|
869
|
+
"product_name": "完全不存在的商品XYZ",
|
|
870
|
+
"size": "中杯",
|
|
871
|
+
"milk_base": "牛奶",
|
|
872
|
+
"temperature": "少冰",
|
|
873
|
+
"sugar": "七分糖",
|
|
874
|
+
"tea_base": None,
|
|
875
|
+
}
|
|
876
|
+
r5 = match_single(t5, master)
|
|
877
|
+
check(r5["confidence"] == LOW_CONFIDENCE, f"置信度 LOW_CONFIDENCE(实际 {r5['confidence']})")
|
|
878
|
+
check(r5["match_type"] == MATCH_BEST_GUESS, f"匹配类型 best_guess(实际 {r5['match_type']})")
|
|
879
|
+
check(r5["sop"] != "", "兜底仍返回了 SOP(最佳猜测)")
|
|
880
|
+
print()
|
|
881
|
+
|
|
882
|
+
# ── 6. 属性不匹配 → LOW_CONFIDENCE ──
|
|
883
|
+
print("6. 商品名匹配但属性不匹配")
|
|
884
|
+
t6 = {
|
|
885
|
+
"product_name": "浅浅清茶",
|
|
886
|
+
"size": "超大杯", # 主数据没有超大杯
|
|
887
|
+
"milk_base": "牛奶",
|
|
888
|
+
"temperature": "少冰",
|
|
889
|
+
"sugar": "七分糖",
|
|
890
|
+
"tea_base": None,
|
|
891
|
+
}
|
|
892
|
+
r6 = match_single(t6, master)
|
|
893
|
+
check(r6["confidence"] == LOW_CONFIDENCE, "置信度 LOW_CONFIDENCE")
|
|
894
|
+
check("size" in r6["unmatched_attributes"], "size 在不匹配属性中")
|
|
895
|
+
print()
|
|
896
|
+
|
|
897
|
+
# ── 7. 批量匹配 ──
|
|
898
|
+
print("7. 批量匹配 match()")
|
|
899
|
+
batch_results = match(
|
|
900
|
+
[t1, t2, t3, t4b, t5, t6],
|
|
901
|
+
master,
|
|
902
|
+
)
|
|
903
|
+
check(len(batch_results) == 6, f"6 条结果(实际 {len(batch_results)})")
|
|
904
|
+
check(batch_results[0]["confidence"] == HIGH, "第 1 条 HIGH")
|
|
905
|
+
check(batch_results[4]["confidence"] == LOW_CONFIDENCE, "第 5 条 LOW_CONFIDENCE")
|
|
906
|
+
check(batch_results[5]["confidence"] == LOW_CONFIDENCE, "第 6 条 LOW_CONFIDENCE")
|
|
907
|
+
print()
|
|
908
|
+
|
|
909
|
+
# ── 8. 空模板行 ──
|
|
910
|
+
print("8. 空模板行处理")
|
|
911
|
+
t8 = {
|
|
912
|
+
"product_name": "",
|
|
913
|
+
"size": None,
|
|
914
|
+
"milk_base": None,
|
|
915
|
+
"temperature": None,
|
|
916
|
+
"sugar": None,
|
|
917
|
+
"tea_base": None,
|
|
918
|
+
}
|
|
919
|
+
r8 = match_single(t8, master)
|
|
920
|
+
check(r8["confidence"] == LOW_CONFIDENCE, "空商品名 → LOW_CONFIDENCE")
|
|
921
|
+
check(r8["product_score"] == 0, "分数为 0")
|
|
922
|
+
print()
|
|
923
|
+
|
|
924
|
+
# ── 9. 空 template_rows ──
|
|
925
|
+
print("9. 空模板列表")
|
|
926
|
+
r9 = match([], master)
|
|
927
|
+
check(r9 == [], "空模板 → 空结果")
|
|
928
|
+
print()
|
|
929
|
+
|
|
930
|
+
# ── 10. 空主数据 ──
|
|
931
|
+
print("10. 空主数据处理")
|
|
932
|
+
try:
|
|
933
|
+
match([t1], [])
|
|
934
|
+
check(False, "match() 空 master 应抛异常")
|
|
935
|
+
except ValueError as e:
|
|
936
|
+
check("不能为空" in str(e), f"match() ValueError: {e}")
|
|
937
|
+
|
|
938
|
+
# match_single 空 master → 优雅降级
|
|
939
|
+
r10 = match_single(t1, [])
|
|
940
|
+
check(r10["confidence"] == LOW_CONFIDENCE, "match_single 空 master → LOW_CONFIDENCE")
|
|
941
|
+
check(r10["master_index"] == -1, "match_single 空 master → master_index=-1")
|
|
942
|
+
print()
|
|
943
|
+
|
|
944
|
+
# ── 11. 同名产品多个候选按属性精确选择 ──
|
|
945
|
+
print("11. 多候选精确属性匹配")
|
|
946
|
+
# 浅浅清茶有 3 行不同属性,应精确选中大杯/正常冰/全糖
|
|
947
|
+
t11 = {
|
|
948
|
+
"product_name": "浅浅清茶",
|
|
949
|
+
"size": "大杯",
|
|
950
|
+
"milk_base": "牛奶",
|
|
951
|
+
"temperature": "正常冰",
|
|
952
|
+
"sugar": "全糖",
|
|
953
|
+
"tea_base": None,
|
|
954
|
+
}
|
|
955
|
+
r11 = match_single(t11, master)
|
|
956
|
+
check(r11["confidence"] == HIGH, "置信度 HIGH")
|
|
957
|
+
check(r11["sop"] == "T300、B40/120、S6、IC(S)、MS 3-5", "选中大杯 SOP")
|
|
958
|
+
print()
|
|
959
|
+
|
|
960
|
+
# ── 12. 报告生成 ──
|
|
961
|
+
print("12. 报告生成")
|
|
962
|
+
report = generate_report(batch_results)
|
|
963
|
+
check("需要确认:2 行" in report, "报告显示 2 条需要确认")
|
|
964
|
+
check("详细日志" in report, "报告包含详细日志段")
|
|
965
|
+
check("完全失败:0 行" in report, "报告显示完全失败 0 行")
|
|
966
|
+
# 全 HIGH 报告
|
|
967
|
+
high_only = [batch_results[0], batch_results[1], batch_results[2], batch_results[3], r11]
|
|
968
|
+
report_high = generate_report(high_only)
|
|
969
|
+
check("需要确认" not in report_high, "全 HIGH 报告不含需要确认段")
|
|
970
|
+
check("高置信匹配:5 行" in report_high, "全 HIGH 报告显示 5 行高置信匹配")
|
|
971
|
+
print()
|
|
972
|
+
|
|
973
|
+
# ── 汇总 ──
|
|
974
|
+
print(f"=== 结果: {passed} passed, {failed} failed ===")
|