专长:增强 AI 设置和搜索功能
- 更新了 AI 设置页面,统一了 AI 补充、自然语言搜索、入站预处理和标签标准化的配置。 - 在编辑页面新增了 AI 标签和笔记标准化板块,包括建议预览及应用功能。 - 改进的搜索页面,支持带有示例的自然语言查询和模糊选择下拉菜单。 - 增强的搜索结果显示,包含更多匹配信息和查看 AI 搜索过程的模态。 - 更新新组件样式,优化布局以提升用户体验。
This commit is contained in:
20
README.md
20
README.md
@@ -108,10 +108,12 @@ $env:SILICONFLOW_MODEL="Qwen/Qwen2.5-7B-Instruct"
|
|||||||
- `28格/14格/自定义容器` 支持立创编号入库:进入对应格位编辑页后输入编号,自动拉取商品基础信息并写入当前格位。
|
- `28格/14格/自定义容器` 支持立创编号入库:进入对应格位编辑页后输入编号,自动拉取商品基础信息并写入当前格位。
|
||||||
- 支持按当前盒子导出打标 CSV(仅导出启用记录),可用于热敏打标机导入。
|
- 支持按当前盒子导出打标 CSV(仅导出启用记录),可用于热敏打标机导入。
|
||||||
- 打标 CSV 列名为中英双语格式(如 `料号(part_no)`、`备注(note)`),便于直接识别。
|
- 打标 CSV 列名为中英双语格式(如 `料号(part_no)`、`备注(note)`),便于直接识别。
|
||||||
|
- 打标 CSV 新增 `短标签(short_label)` 与 `搜索关键词(search_keywords)` 列,便于打标和后续检索。
|
||||||
|
|
||||||
### 3.3 编辑页 `/edit/<box_id>/<slot>`
|
### 3.3 编辑页 `/edit/<box_id>/<slot>`
|
||||||
|
|
||||||
- 编辑料号、名称、规格、数量、备注。
|
- 编辑料号、名称、规格、数量、备注。
|
||||||
|
- 新增 `AI 标签与备注标准化`:可生成更适合标签打印的短标签、建议名称、建议备注和搜索关键词,确认后再回填表单。
|
||||||
- 通过按钮启用/停用。
|
- 通过按钮启用/停用。
|
||||||
- 可删除当前格子记录。
|
- 可删除当前格子记录。
|
||||||
|
|
||||||
@@ -129,7 +131,8 @@ $env:SILICONFLOW_MODEL="Qwen/Qwen2.5-7B-Instruct"
|
|||||||
|
|
||||||
### 3.5 快速搜索与出库 `/search`
|
### 3.5 快速搜索与出库 `/search`
|
||||||
|
|
||||||
- 支持按 `料号` 或 `名称` 搜索已启用元件。
|
- 支持自然语言搜索,例如 `3.3V 稳压芯片`、`0805 常用电阻`、`USB 相关器件`。
|
||||||
|
- 会自动把搜索词映射到 `料号 / 名称 / 规格 / 备注` 组合搜索,并显示解析结果。
|
||||||
- 搜索结果可一键跳转到对应盒位编辑页。
|
- 搜索结果可一键跳转到对应盒位编辑页。
|
||||||
- 支持快速出库:只填写数量即可扣减库存,并写入统计日志。
|
- 支持快速出库:只填写数量即可扣减库存,并写入统计日志。
|
||||||
|
|
||||||
@@ -141,6 +144,7 @@ $env:SILICONFLOW_MODEL="Qwen/Qwen2.5-7B-Instruct"
|
|||||||
### 3.7 AI 参数设置 `/ai/settings`
|
### 3.7 AI 参数设置 `/ai/settings`
|
||||||
|
|
||||||
- 支持页面内编辑:`API URL / 模型名称 / API Key / 超时 / 低库存阈值 / 建议条目上限`。
|
- 支持页面内编辑:`API URL / 模型名称 / API Key / 超时 / 低库存阈值 / 建议条目上限`。
|
||||||
|
- 同一套 AI 参数同时用于:入库预处理、自然语言搜索、重复巡检、补货建议、标签与备注标准化。
|
||||||
- 支持页面内编辑立创接口参数:`Base URL / Path / API Key / Header / Prefix / 请求编号字段 / 超时`。
|
- 支持页面内编辑立创接口参数:`Base URL / Path / API Key / Header / Prefix / 请求编号字段 / 超时`。
|
||||||
- 保存后立即生效,无需改代码。
|
- 保存后立即生效,无需改代码。
|
||||||
|
|
||||||
@@ -379,14 +383,14 @@ cp /www/wwwroot/inventory/data/inventory.db /www/backup/inventory_$(date +%F).db
|
|||||||
|
|
||||||
### 第四阶段:提升查找和录入体验
|
### 第四阶段:提升查找和录入体验
|
||||||
|
|
||||||
- [ ] AI 自然语言搜索
|
- [x] AI 自然语言搜索
|
||||||
- [ ] 支持搜索“3.3V 稳压芯片”“0805 常用电阻”“USB 相关器件”这类自然语言
|
- [x] 支持搜索“3.3V 稳压芯片”“0805 常用电阻”“USB 相关器件”这类自然语言
|
||||||
- [ ] 将自然语言自动映射到 `名称 / 规格 / 备注 / 料号` 的组合搜索
|
- [x] 将自然语言自动映射到 `名称 / 规格 / 备注 / 料号` 的组合搜索
|
||||||
|
|
||||||
- [ ] AI 标签与备注标准化
|
- [x] AI 标签与备注标准化
|
||||||
- [ ] 自动生成更适合标签打印的短名称
|
- [x] 自动生成更适合标签打印的短名称
|
||||||
- [ ] 自动补全更统一的备注格式和搜索关键词
|
- [x] 自动补全更统一的备注格式和搜索关键词
|
||||||
- [ ] 让名称更短、备注更规范,方便后续检索和盘点
|
- [x] 让名称更短、备注更规范,方便后续检索和盘点
|
||||||
|
|
||||||
### 第五阶段:做更深层的数据分析
|
### 第五阶段:做更深层的数据分析
|
||||||
|
|
||||||
|
|||||||
737
app.py
737
app.py
@@ -11,6 +11,7 @@ import re
|
|||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
import hmac
|
import hmac
|
||||||
|
import difflib
|
||||||
import base64
|
import base64
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
@@ -94,6 +95,83 @@ DEFAULT_BOX_TYPES = {
|
|||||||
|
|
||||||
BOX_TYPES = deepcopy(DEFAULT_BOX_TYPES)
|
BOX_TYPES = deepcopy(DEFAULT_BOX_TYPES)
|
||||||
|
|
||||||
|
SEARCH_GENERIC_TERMS = {
|
||||||
|
"元件",
|
||||||
|
"器件",
|
||||||
|
"相关",
|
||||||
|
"相关器件",
|
||||||
|
"型号",
|
||||||
|
"物料",
|
||||||
|
"库存",
|
||||||
|
"电子",
|
||||||
|
}
|
||||||
|
SEARCH_NOTE_HINT_TERMS = {
|
||||||
|
"常用",
|
||||||
|
"项目",
|
||||||
|
"样品",
|
||||||
|
"替代",
|
||||||
|
"调试",
|
||||||
|
"电源",
|
||||||
|
"测试",
|
||||||
|
"备件",
|
||||||
|
}
|
||||||
|
COMPONENT_CATEGORY_HINTS = [
|
||||||
|
("电阻", ["电阻", "resistor", "res"]),
|
||||||
|
("电容", ["电容", "capacitor", "cap"]),
|
||||||
|
("电感", ["电感", "inductor"]),
|
||||||
|
("稳压", ["稳压", "ldo", "regulator", "dc-dc", "dcdc"]),
|
||||||
|
("二极管", ["二极管", "diode", "tvs", "esd"]),
|
||||||
|
("三极管", ["三极管", "transistor", "mos", "mosfet", "bjt"]),
|
||||||
|
("接口", ["usb", "type-c", "uart", "rs485", "i2c", "spi", "can"]),
|
||||||
|
("MCU", ["mcu", "stm32", "esp32", "avr", "单片机"]),
|
||||||
|
("存储", ["eeprom", "flash", "存储"]),
|
||||||
|
("晶振", ["晶振", "oscillator", "crystal"]),
|
||||||
|
("连接器", ["连接器", "connector", "header", "socket"]),
|
||||||
|
("传感器", ["sensor", "传感器"]),
|
||||||
|
("驱动", ["driver", "驱动"]),
|
||||||
|
]
|
||||||
|
SEARCH_FIELD_WEIGHTS = {
|
||||||
|
"part_no": 6,
|
||||||
|
"name": 5,
|
||||||
|
"specification": 4,
|
||||||
|
"note": 3,
|
||||||
|
}
|
||||||
|
SEARCH_FUZZY_PROFILES = {
|
||||||
|
"strict": {
|
||||||
|
"label": "严格",
|
||||||
|
"field_hit": 0.8,
|
||||||
|
"combined_hit": 0.78,
|
||||||
|
"soft_hit": 0.66,
|
||||||
|
"keyword_hit": 0.74,
|
||||||
|
"keyword_soft": 0.62,
|
||||||
|
"score_gate": 6.0,
|
||||||
|
"coverage_gate": 0.48,
|
||||||
|
"high_fuzzy_gate": 0.9,
|
||||||
|
},
|
||||||
|
"balanced": {
|
||||||
|
"label": "平衡",
|
||||||
|
"field_hit": 0.75,
|
||||||
|
"combined_hit": 0.72,
|
||||||
|
"soft_hit": 0.6,
|
||||||
|
"keyword_hit": 0.7,
|
||||||
|
"keyword_soft": 0.58,
|
||||||
|
"score_gate": 4.5,
|
||||||
|
"coverage_gate": 0.35,
|
||||||
|
"high_fuzzy_gate": 0.82,
|
||||||
|
},
|
||||||
|
"loose": {
|
||||||
|
"label": "宽松",
|
||||||
|
"field_hit": 0.7,
|
||||||
|
"combined_hit": 0.66,
|
||||||
|
"soft_hit": 0.54,
|
||||||
|
"keyword_hit": 0.66,
|
||||||
|
"keyword_soft": 0.52,
|
||||||
|
"score_gate": 3.0,
|
||||||
|
"coverage_gate": 0.22,
|
||||||
|
"high_fuzzy_gate": 0.74,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _apply_box_type_overrides() -> None:
|
def _apply_box_type_overrides() -> None:
|
||||||
"""加载盒型覆盖配置。
|
"""加载盒型覆盖配置。
|
||||||
@@ -1515,6 +1593,564 @@ def _pick_standard_text(values: list[str]) -> str:
|
|||||||
return ordered[0][0]
|
return ordered[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_spaces(text: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", (text or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_text_list(values: list[str], limit: int | None = None) -> list[str]:
|
||||||
|
seen = set()
|
||||||
|
rows = []
|
||||||
|
for value in values:
|
||||||
|
text = _compact_spaces(str(value or ""))
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
key = _normalize_material_text(text)
|
||||||
|
if not key or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
rows.append(text)
|
||||||
|
if limit is not None and len(rows) >= limit:
|
||||||
|
break
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _split_natural_language_terms(query: str) -> list[str]:
|
||||||
|
raw = _compact_spaces(query)
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
|
||||||
|
normalized = re.sub(r"[,,;/|]+", " ", raw)
|
||||||
|
parts = [p.strip() for p in re.split(r"\s+", normalized) if p.strip()]
|
||||||
|
|
||||||
|
if len(parts) <= 1:
|
||||||
|
parts = re.findall(r"[A-Za-z0-9.+#%-]+(?:-[A-Za-z0-9.+#%-]+)?|[\u4e00-\u9fff]{1,}", normalized)
|
||||||
|
|
||||||
|
return _dedupe_text_list(parts, limit=10)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_part_no_term(term: str) -> bool:
|
||||||
|
upper = (term or "").strip().upper()
|
||||||
|
if not upper:
|
||||||
|
return False
|
||||||
|
if re.fullmatch(r"C\d{3,}", upper):
|
||||||
|
return True
|
||||||
|
if re.search(r"[A-Z]", upper) and re.search(r"\d", upper) and len(upper) >= 6:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_package_term(term: str) -> bool:
|
||||||
|
upper = (term or "").strip().upper()
|
||||||
|
if not upper:
|
||||||
|
return False
|
||||||
|
return bool(
|
||||||
|
re.fullmatch(r"(?:0201|0402|0603|0805|1206|1210|1812|2512)", upper)
|
||||||
|
or re.fullmatch(r"(?:SOT|SOP|SOIC|QFN|QFP|LQFP|TQFP|DIP|TO|DFN|BGA)[- ]?\d+[A-Z-]*", upper)
|
||||||
|
or re.fullmatch(r"[A-Z]{2,6}-\d{1,3}", upper)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_spec_term(term: str) -> bool:
|
||||||
|
upper = (term or "").strip().upper()
|
||||||
|
if not upper:
|
||||||
|
return False
|
||||||
|
if _looks_like_package_term(upper):
|
||||||
|
return True
|
||||||
|
return bool(
|
||||||
|
re.search(r"\d(?:\.\d+)?\s?(?:V|A|MA|UA|OHM|R|K|M|UF|NF|PF|UH|MH|W|%|MHZ|GHZ|KB|MB|BIT)\b", upper)
|
||||||
|
or upper in {"USB", "TYPE-C", "X7R", "X5R", "NPO", "COG", "UART", "I2C", "SPI", "CAN", "LDO", "DC-DC"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rule_based_search_plan(query: str) -> dict:
|
||||||
|
"""把自然语言查询映射为多字段组合搜索计划。
|
||||||
|
|
||||||
|
中文说明:这里先做一层规则解析,把用户输入拆成“更像料号 / 更像规格 / 更像备注 / 更像名称”
|
||||||
|
的字段集合;这样即使没有配置 AI,也能支持如“3.3V 稳压芯片”“0805 常用电阻”这类查询。
|
||||||
|
"""
|
||||||
|
terms = _split_natural_language_terms(query)
|
||||||
|
field_map = {
|
||||||
|
"part_no": [],
|
||||||
|
"name": [],
|
||||||
|
"specification": [],
|
||||||
|
"note": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for term in terms:
|
||||||
|
lowered = term.lower()
|
||||||
|
if term in SEARCH_GENERIC_TERMS:
|
||||||
|
continue
|
||||||
|
if _looks_like_part_no_term(term):
|
||||||
|
field_map["part_no"].append(term.upper())
|
||||||
|
continue
|
||||||
|
if _looks_like_spec_term(term):
|
||||||
|
field_map["specification"].append(term)
|
||||||
|
continue
|
||||||
|
if term in SEARCH_NOTE_HINT_TERMS or lowered in SEARCH_NOTE_HINT_TERMS:
|
||||||
|
field_map["note"].append(term)
|
||||||
|
continue
|
||||||
|
field_map["name"].append(term)
|
||||||
|
|
||||||
|
for key in field_map:
|
||||||
|
field_map[key] = _dedupe_text_list(field_map[key], limit=6)
|
||||||
|
|
||||||
|
keywords = _dedupe_text_list(
|
||||||
|
field_map["part_no"] + field_map["name"] + field_map["specification"] + field_map["note"],
|
||||||
|
limit=10,
|
||||||
|
)
|
||||||
|
summary_bits = []
|
||||||
|
field_labels = {
|
||||||
|
"part_no": "料号",
|
||||||
|
"name": "名称",
|
||||||
|
"specification": "规格",
|
||||||
|
"note": "备注",
|
||||||
|
}
|
||||||
|
for field, values in field_map.items():
|
||||||
|
if values:
|
||||||
|
summary_bits.append(f"{field_labels[field]}: {' / '.join(values)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"query": query,
|
||||||
|
"mode": "rule",
|
||||||
|
"field_map": field_map,
|
||||||
|
"keywords": keywords,
|
||||||
|
"summary": ";".join(summary_bits) if summary_bits else "未识别到明确字段,按全文模糊搜索",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_search_plan(raw_plan: dict, fallback_plan: dict) -> dict:
|
||||||
|
if not isinstance(raw_plan, dict):
|
||||||
|
return fallback_plan
|
||||||
|
|
||||||
|
field_map = {}
|
||||||
|
for field in ("part_no", "name", "specification", "note"):
|
||||||
|
raw_values = raw_plan.get(field, fallback_plan["field_map"].get(field, []))
|
||||||
|
if isinstance(raw_values, str):
|
||||||
|
raw_values = [raw_values]
|
||||||
|
if not isinstance(raw_values, list):
|
||||||
|
raw_values = fallback_plan["field_map"].get(field, [])
|
||||||
|
field_map[field] = _dedupe_text_list(raw_values, limit=6)
|
||||||
|
|
||||||
|
keywords = raw_plan.get("keywords", [])
|
||||||
|
if isinstance(keywords, str):
|
||||||
|
keywords = re.split(r"[,,/|\s]+", keywords)
|
||||||
|
if not isinstance(keywords, list):
|
||||||
|
keywords = []
|
||||||
|
keywords = _dedupe_text_list(keywords, limit=10)
|
||||||
|
if not keywords:
|
||||||
|
keywords = _dedupe_text_list(
|
||||||
|
field_map["part_no"] + field_map["name"] + field_map["specification"] + field_map["note"],
|
||||||
|
limit=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = _compact_spaces(str(raw_plan.get("summary", "") or "")) or fallback_plan.get("summary", "")
|
||||||
|
if not any(field_map.values()):
|
||||||
|
return fallback_plan
|
||||||
|
|
||||||
|
return {
|
||||||
|
"query": fallback_plan.get("query", ""),
|
||||||
|
"mode": "ai",
|
||||||
|
"field_map": field_map,
|
||||||
|
"keywords": keywords,
|
||||||
|
"summary": summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_search_plan(query: str, settings: dict) -> tuple[dict, str, dict]:
|
||||||
|
fallback_plan = _build_rule_based_search_plan(query)
|
||||||
|
trace = {
|
||||||
|
"query": query,
|
||||||
|
"fallback_plan": fallback_plan,
|
||||||
|
"used_ai": False,
|
||||||
|
"used_fallback": False,
|
||||||
|
"ai_raw": "",
|
||||||
|
"ai_error": "",
|
||||||
|
"final_mode": "rule",
|
||||||
|
}
|
||||||
|
api_key = (settings.get("api_key") or "").strip()
|
||||||
|
api_url = (settings.get("api_url") or "").strip()
|
||||||
|
model = (settings.get("model") or "").strip()
|
||||||
|
if not api_key or not api_url or not model:
|
||||||
|
trace["used_fallback"] = True
|
||||||
|
return fallback_plan, "", trace
|
||||||
|
|
||||||
|
system_prompt = (
|
||||||
|
"你是电子元件库存搜索解析助手。"
|
||||||
|
"必须只输出 JSON,不要 Markdown,不要解释文字。"
|
||||||
|
"输出格式: {\"part_no\":[string],\"name\":[string],\"specification\":[string],\"note\":[string],\"keywords\":[string],\"summary\":string}。"
|
||||||
|
"目标是把自然语言查询拆成适合库存系统组合搜索的字段词。"
|
||||||
|
"不要虚构料号;每个数组最多 6 项。"
|
||||||
|
)
|
||||||
|
user_prompt = (
|
||||||
|
"用户搜索词:\n"
|
||||||
|
+ json.dumps({"query": query, "fallback": fallback_plan}, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
suggestion = _call_siliconflow_chat(
|
||||||
|
system_prompt,
|
||||||
|
user_prompt,
|
||||||
|
api_url=api_url,
|
||||||
|
model=model,
|
||||||
|
api_key=api_key,
|
||||||
|
timeout=int(settings.get("timeout", 30)),
|
||||||
|
)
|
||||||
|
trace["used_ai"] = True
|
||||||
|
trace["ai_raw"] = suggestion
|
||||||
|
parsed = json.loads(_extract_json_object_block(suggestion))
|
||||||
|
final_plan = _normalize_search_plan(parsed, fallback_plan)
|
||||||
|
trace["final_mode"] = final_plan.get("mode", "ai")
|
||||||
|
trace["used_fallback"] = trace["final_mode"] != "ai"
|
||||||
|
return final_plan, "", trace
|
||||||
|
except Exception as exc:
|
||||||
|
trace["used_fallback"] = True
|
||||||
|
trace["ai_error"] = str(exc)
|
||||||
|
return fallback_plan, "AI 搜索解析失败,已回退到规则搜索", trace
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_search_fuzziness(raw: str) -> str:
|
||||||
|
mode = (raw or "balanced").strip().lower()
|
||||||
|
if mode not in SEARCH_FUZZY_PROFILES:
|
||||||
|
mode = "balanced"
|
||||||
|
return mode
|
||||||
|
|
||||||
|
|
||||||
|
def _search_text_contains(text: str, term: str) -> bool:
|
||||||
|
normalized_text = _normalize_material_text(text)
|
||||||
|
normalized_term = _normalize_material_text(term)
|
||||||
|
if not normalized_text or not normalized_term:
|
||||||
|
return False
|
||||||
|
return normalized_term in normalized_text
|
||||||
|
|
||||||
|
|
||||||
|
def _fuzzy_ratio(a: str, b: str) -> float:
|
||||||
|
"""计算两个字符串的相似度,用于搜索兜底模糊匹配。"""
|
||||||
|
left = _normalize_material_text(a)
|
||||||
|
right = _normalize_material_text(b)
|
||||||
|
if not left or not right:
|
||||||
|
return 0.0
|
||||||
|
return difflib.SequenceMatcher(None, left, right).ratio()
|
||||||
|
|
||||||
|
|
||||||
|
def _fuzzy_term_match_score(text: str, term: str) -> float:
|
||||||
|
"""对单个词做宽松匹配评分。
|
||||||
|
|
||||||
|
中文说明:先尝试直接包含匹配;不命中时再做片段相似度,
|
||||||
|
避免搜索词稍有差异(如“稳压器/稳压芯片”)就完全漏检。
|
||||||
|
"""
|
||||||
|
normalized_text = _normalize_material_text(text)
|
||||||
|
normalized_term = _normalize_material_text(term)
|
||||||
|
if not normalized_text or not normalized_term:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
if normalized_term in normalized_text:
|
||||||
|
term_len = max(len(normalized_term), 1)
|
||||||
|
bonus = min(term_len / 12.0, 0.35)
|
||||||
|
return min(1.0, 0.75 + bonus)
|
||||||
|
|
||||||
|
if len(normalized_term) <= 1:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
best = _fuzzy_ratio(normalized_text, normalized_term)
|
||||||
|
window = len(normalized_term)
|
||||||
|
if len(normalized_text) > window and window >= 2:
|
||||||
|
step = 1 if window <= 4 else 2
|
||||||
|
for idx in range(0, len(normalized_text) - window + 1, step):
|
||||||
|
chunk = normalized_text[idx : idx + window]
|
||||||
|
ratio = _fuzzy_ratio(chunk, normalized_term)
|
||||||
|
if ratio > best:
|
||||||
|
best = ratio
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _search_component_match_info(component: Component, plan: dict, fuzziness: str = "balanced") -> dict:
|
||||||
|
profile = SEARCH_FUZZY_PROFILES.get(fuzziness, SEARCH_FUZZY_PROFILES["balanced"])
|
||||||
|
field_texts = {
|
||||||
|
"part_no": component.part_no or "",
|
||||||
|
"name": component.name or "",
|
||||||
|
"specification": component.specification or "",
|
||||||
|
"note": component.note or "",
|
||||||
|
}
|
||||||
|
combined_text = " ".join(field_texts.values())
|
||||||
|
matched_fields = set()
|
||||||
|
matched_terms = []
|
||||||
|
score = 0
|
||||||
|
total_terms = 0
|
||||||
|
fuzzy_matches = []
|
||||||
|
|
||||||
|
for field, terms in plan.get("field_map", {}).items():
|
||||||
|
for term in terms:
|
||||||
|
total_terms += 1
|
||||||
|
field_score = _fuzzy_term_match_score(field_texts.get(field, ""), term)
|
||||||
|
combined_score = _fuzzy_term_match_score(combined_text, term)
|
||||||
|
|
||||||
|
if field_score >= profile["field_hit"]:
|
||||||
|
score += SEARCH_FIELD_WEIGHTS.get(field, 1) + 1
|
||||||
|
matched_fields.add(field)
|
||||||
|
matched_terms.append(term)
|
||||||
|
fuzzy_matches.append({"term": term, "score": round(field_score, 3), "field": field})
|
||||||
|
elif field != "part_no" and combined_score >= profile["combined_hit"]:
|
||||||
|
score += SEARCH_FIELD_WEIGHTS.get(field, 1)
|
||||||
|
matched_fields.add(field)
|
||||||
|
matched_terms.append(term)
|
||||||
|
fuzzy_matches.append({"term": term, "score": round(combined_score, 3), "field": "all"})
|
||||||
|
elif max(field_score, combined_score) >= profile["soft_hit"]:
|
||||||
|
# 低分模糊命中只给轻权重,避免误召回过多。
|
||||||
|
score += 1
|
||||||
|
matched_terms.append(term)
|
||||||
|
fuzzy_matches.append(
|
||||||
|
{
|
||||||
|
"term": term,
|
||||||
|
"score": round(max(field_score, combined_score), 3),
|
||||||
|
"field": field if field_score >= combined_score else "all",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for term in plan.get("keywords", []):
|
||||||
|
if term in matched_terms:
|
||||||
|
continue
|
||||||
|
keyword_score = _fuzzy_term_match_score(combined_text, term)
|
||||||
|
if keyword_score >= profile["keyword_hit"]:
|
||||||
|
score += 1
|
||||||
|
matched_terms.append(term)
|
||||||
|
fuzzy_matches.append({"term": term, "score": round(keyword_score, 3), "field": "all"})
|
||||||
|
elif keyword_score >= profile["keyword_soft"]:
|
||||||
|
score += 0.5
|
||||||
|
matched_terms.append(term)
|
||||||
|
fuzzy_matches.append({"term": term, "score": round(keyword_score, 3), "field": "all"})
|
||||||
|
|
||||||
|
unique_matched_terms = _dedupe_text_list(matched_terms, limit=8)
|
||||||
|
coverage = len(unique_matched_terms) / max(total_terms or len(plan.get("keywords", [])) or 1, 1)
|
||||||
|
|
||||||
|
is_match = False
|
||||||
|
if score >= profile["score_gate"]:
|
||||||
|
is_match = True
|
||||||
|
elif unique_matched_terms and coverage >= profile["coverage_gate"]:
|
||||||
|
is_match = True
|
||||||
|
elif plan.get("keywords") and len(plan.get("keywords", [])) == 1 and unique_matched_terms:
|
||||||
|
is_match = True
|
||||||
|
elif any(item["score"] >= profile["high_fuzzy_gate"] for item in fuzzy_matches):
|
||||||
|
is_match = True
|
||||||
|
|
||||||
|
return {
|
||||||
|
"is_match": is_match,
|
||||||
|
"score": score,
|
||||||
|
"coverage": coverage,
|
||||||
|
"matched_terms": unique_matched_terms,
|
||||||
|
"matched_fields": sorted(matched_fields),
|
||||||
|
"fuzzy_matches": sorted(fuzzy_matches, key=lambda row: row["score"], reverse=True)[:6],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_component_category(part_no: str, name: str, specification: str, note: str) -> str:
|
||||||
|
combined = " ".join([part_no or "", name or "", specification or "", note or ""]).lower()
|
||||||
|
for label, patterns in COMPONENT_CATEGORY_HINTS:
|
||||||
|
for pattern in patterns:
|
||||||
|
if pattern in combined:
|
||||||
|
return label
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_primary_package(specification: str, name: str = "", note: str = "") -> str:
|
||||||
|
spec_fields = _parse_slot_spec_fields(specification)
|
||||||
|
package = _compact_spaces(spec_fields.get("package", ""))
|
||||||
|
if package:
|
||||||
|
return package
|
||||||
|
|
||||||
|
combined = " ".join([name or "", specification or "", note or ""])
|
||||||
|
match = re.search(
|
||||||
|
r"\b(0201|0402|0603|0805|1206|1210|1812|2512|SOT-23(?:-\d+)?|SOP-?\d+|SOIC-?\d+|QFN-?\d+|QFP-?\d+|LQFP-?\d+|TQFP-?\d+|DIP-?\d+|DFN-?\d+|TO-?\d+)\b",
|
||||||
|
combined,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
return match.group(1).upper() if match else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_component_keywords(part_no: str, name: str, specification: str, note: str) -> list[str]:
|
||||||
|
combined = " ".join([part_no or "", name or "", specification or "", note or ""])
|
||||||
|
keywords = []
|
||||||
|
category = _infer_component_category(part_no, name, specification, note)
|
||||||
|
if category:
|
||||||
|
keywords.append(category)
|
||||||
|
|
||||||
|
package = _extract_primary_package(specification, name=name, note=note)
|
||||||
|
if package:
|
||||||
|
keywords.append(package)
|
||||||
|
|
||||||
|
lcsc_code = _extract_lcsc_code_from_text(note or part_no or "")
|
||||||
|
if lcsc_code:
|
||||||
|
keywords.append(lcsc_code)
|
||||||
|
|
||||||
|
value_patterns = [
|
||||||
|
r"\b\d+(?:\.\d+)?\s?(?:V|A|mA|uA|W|MHz|GHz|KB|MB|bit)\b",
|
||||||
|
r"\b\d+(?:\.\d+)?\s?(?:K|M|R|ohm|Ω)\b",
|
||||||
|
r"\b\d+(?:\.\d+)?\s?(?:uF|nF|pF|uH|mH)\b",
|
||||||
|
r"\b\d+%\b",
|
||||||
|
r"\b(?:USB|TYPE-C|UART|I2C|SPI|CAN|RS485|LDO|DC-DC|X7R|X5R|NPO|COG)\b",
|
||||||
|
]
|
||||||
|
for pattern in value_patterns:
|
||||||
|
for match in re.findall(pattern, combined, flags=re.IGNORECASE):
|
||||||
|
keywords.append(_compact_spaces(str(match)).upper().replace("MA", "mA").replace("UA", "uA"))
|
||||||
|
|
||||||
|
for token in _split_natural_language_terms(name):
|
||||||
|
if token in SEARCH_GENERIC_TERMS or len(token) <= 1:
|
||||||
|
continue
|
||||||
|
if _looks_like_part_no_term(token):
|
||||||
|
continue
|
||||||
|
keywords.append(token)
|
||||||
|
|
||||||
|
for token in _split_natural_language_terms(note):
|
||||||
|
if token in SEARCH_GENERIC_TERMS or len(token) <= 1:
|
||||||
|
continue
|
||||||
|
keywords.append(token)
|
||||||
|
|
||||||
|
return _dedupe_text_list(keywords, limit=8)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_text(text: str, limit: int) -> str:
|
||||||
|
raw = _compact_spaces(text)
|
||||||
|
if len(raw) <= limit:
|
||||||
|
return raw
|
||||||
|
return raw[: max(limit - 1, 1)].rstrip(" -_/|") + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_standardized_note(note: str, keywords: list[str]) -> str:
|
||||||
|
segments = []
|
||||||
|
for chunk in re.split(r"[\n|]+", note or ""):
|
||||||
|
text = _compact_spaces(chunk)
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if text.startswith("关键词:"):
|
||||||
|
continue
|
||||||
|
segments.append(text)
|
||||||
|
|
||||||
|
if keywords:
|
||||||
|
segments.append("关键词: " + ", ".join(keywords[:6]))
|
||||||
|
|
||||||
|
return " | ".join(_dedupe_text_list(segments, limit=8))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rule_based_standardization(part_no: str, name: str, specification: str, note: str) -> dict:
|
||||||
|
"""生成标签打印和备注标准化建议。
|
||||||
|
|
||||||
|
中文说明:这里不直接覆盖数据库,而是先给出“短标签 / 建议名称 / 建议备注 / 搜索关键词”
|
||||||
|
供用户确认;即使 AI 不可用,也会用规则生成一个稳定可用的建议结果。
|
||||||
|
"""
|
||||||
|
category = _infer_component_category(part_no, name, specification, note)
|
||||||
|
keywords = _extract_component_keywords(part_no, name, specification, note)
|
||||||
|
package = _extract_primary_package(specification, name=name, note=note)
|
||||||
|
|
||||||
|
main_terms = []
|
||||||
|
for term in keywords:
|
||||||
|
if term in {category, package}:
|
||||||
|
continue
|
||||||
|
if _looks_like_part_no_term(term):
|
||||||
|
continue
|
||||||
|
main_terms.append(term)
|
||||||
|
|
||||||
|
short_bits = []
|
||||||
|
if category:
|
||||||
|
short_bits.append(category)
|
||||||
|
for term in main_terms[:2]:
|
||||||
|
if term not in short_bits:
|
||||||
|
short_bits.append(term)
|
||||||
|
if package and package not in short_bits:
|
||||||
|
short_bits.append(package)
|
||||||
|
|
||||||
|
fallback_name = _compact_spaces(name or "")
|
||||||
|
short_label = _truncate_text(" ".join(short_bits) or fallback_name or part_no or "未命名元件", 18)
|
||||||
|
|
||||||
|
standardized_name = fallback_name
|
||||||
|
if not standardized_name or len(standardized_name) > 24:
|
||||||
|
standardized_name = _truncate_text(" ".join(short_bits) or part_no or fallback_name or "未命名元件", 24)
|
||||||
|
|
||||||
|
standardized_specification = _compact_spaces(specification or "")
|
||||||
|
standardized_note = _compose_standardized_note(note or "", keywords)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"short_label": short_label,
|
||||||
|
"name": standardized_name,
|
||||||
|
"specification": standardized_specification,
|
||||||
|
"note": standardized_note,
|
||||||
|
"keywords": keywords,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_standardization_suggestion(raw: dict, fallback: dict) -> dict:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"short_label": _compact_spaces(str(raw.get("short_label", fallback["short_label"]) or fallback["short_label"])),
|
||||||
|
"name": _compact_spaces(str(raw.get("name", fallback["name"]) or fallback["name"])),
|
||||||
|
"specification": _compact_spaces(str(raw.get("specification", fallback["specification"]) or fallback["specification"])),
|
||||||
|
"note": _compact_spaces(str(raw.get("note", fallback["note"]) or fallback["note"])),
|
||||||
|
"keywords": fallback.get("keywords", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
keywords = raw.get("keywords", fallback.get("keywords", []))
|
||||||
|
if isinstance(keywords, str):
|
||||||
|
keywords = re.split(r"[,,/|\s]+", keywords)
|
||||||
|
if not isinstance(keywords, list):
|
||||||
|
keywords = fallback.get("keywords", [])
|
||||||
|
result["keywords"] = _dedupe_text_list(keywords, limit=8) or fallback.get("keywords", [])
|
||||||
|
|
||||||
|
if not result["short_label"]:
|
||||||
|
result["short_label"] = fallback["short_label"]
|
||||||
|
if not result["name"]:
|
||||||
|
result["name"] = fallback["name"]
|
||||||
|
if not result["note"] or "关键词:" not in result["note"]:
|
||||||
|
result["note"] = _compose_standardized_note(result["note"], result["keywords"])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _build_component_standardization_suggestion(
|
||||||
|
part_no: str,
|
||||||
|
name: str,
|
||||||
|
specification: str,
|
||||||
|
note: str,
|
||||||
|
settings: dict,
|
||||||
|
) -> tuple[dict, str]:
|
||||||
|
fallback = _build_rule_based_standardization(part_no, name, specification, note)
|
||||||
|
api_key = (settings.get("api_key") or "").strip()
|
||||||
|
api_url = (settings.get("api_url") or "").strip()
|
||||||
|
model = (settings.get("model") or "").strip()
|
||||||
|
if not api_key or not api_url or not model:
|
||||||
|
return fallback, ""
|
||||||
|
|
||||||
|
system_prompt = (
|
||||||
|
"你是电子元件标签与备注标准化助手。"
|
||||||
|
"必须只输出 JSON,不要 Markdown,不要解释文字。"
|
||||||
|
"输出格式: {\"short_label\":string,\"name\":string,\"specification\":string,\"note\":string,\"keywords\":[string]}。"
|
||||||
|
"要求: short_label 更适合标签打印,name 更短但仍可检索,note 保留追溯信息并补充统一关键词。"
|
||||||
|
)
|
||||||
|
user_prompt = "元件字段(JSON):\n" + json.dumps(
|
||||||
|
{
|
||||||
|
"part_no": part_no,
|
||||||
|
"name": name,
|
||||||
|
"specification": specification,
|
||||||
|
"note": note,
|
||||||
|
"fallback": fallback,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
suggestion = _call_siliconflow_chat(
|
||||||
|
system_prompt,
|
||||||
|
user_prompt,
|
||||||
|
api_url=api_url,
|
||||||
|
model=model,
|
||||||
|
api_key=api_key,
|
||||||
|
timeout=int(settings.get("timeout", 30)),
|
||||||
|
)
|
||||||
|
parsed = json.loads(_extract_json_object_block(suggestion))
|
||||||
|
return _normalize_standardization_suggestion(parsed, fallback), ""
|
||||||
|
except Exception:
|
||||||
|
return fallback, "AI 标准化失败,已回退到规则建议"
|
||||||
|
|
||||||
|
|
||||||
def _build_duplicate_member(component: Component, box_by_id: dict[int, Box]) -> dict:
|
def _build_duplicate_member(component: Component, box_by_id: dict[int, Box]) -> dict:
|
||||||
box = box_by_id.get(component.box_id)
|
box = box_by_id.get(component.box_id)
|
||||||
lcsc_code = _extract_lcsc_code_from_text(component.note or "")
|
lcsc_code = _extract_lcsc_code_from_text(component.note or "")
|
||||||
@@ -2073,6 +2709,7 @@ def export_box_labels_csv(box_id: int):
|
|||||||
"盒子名称(box_name)",
|
"盒子名称(box_name)",
|
||||||
"位置编号(slot_code)",
|
"位置编号(slot_code)",
|
||||||
"料号(part_no)",
|
"料号(part_no)",
|
||||||
|
"短标签(short_label)",
|
||||||
"名称(name)",
|
"名称(name)",
|
||||||
"品牌(brand)",
|
"品牌(brand)",
|
||||||
"封装(package)",
|
"封装(package)",
|
||||||
@@ -2082,6 +2719,7 @@ def export_box_labels_csv(box_id: int):
|
|||||||
"商品编排(arrange)",
|
"商品编排(arrange)",
|
||||||
"最小包装(min_pack)",
|
"最小包装(min_pack)",
|
||||||
"规格(specification)",
|
"规格(specification)",
|
||||||
|
"搜索关键词(search_keywords)",
|
||||||
"数量(quantity)",
|
"数量(quantity)",
|
||||||
"位置备注(location)",
|
"位置备注(location)",
|
||||||
"备注(note)",
|
"备注(note)",
|
||||||
@@ -2092,6 +2730,7 @@ def export_box_labels_csv(box_id: int):
|
|||||||
slot_code = slot_code_for_box(box, c.slot_index)
|
slot_code = slot_code_for_box(box, c.slot_index)
|
||||||
spec_fields = _parse_slot_spec_fields(c.specification)
|
spec_fields = _parse_slot_spec_fields(c.specification)
|
||||||
note_fields = _parse_note_detail_fields(c.note)
|
note_fields = _parse_note_detail_fields(c.note)
|
||||||
|
standardization = _build_rule_based_standardization(c.part_no, c.name, c.specification, c.note)
|
||||||
if not note_fields["lcsc_code"]:
|
if not note_fields["lcsc_code"]:
|
||||||
note_fields["lcsc_code"] = _extract_lcsc_code_from_text(c.part_no)
|
note_fields["lcsc_code"] = _extract_lcsc_code_from_text(c.part_no)
|
||||||
writer.writerow(
|
writer.writerow(
|
||||||
@@ -2099,6 +2738,7 @@ def export_box_labels_csv(box_id: int):
|
|||||||
box.name,
|
box.name,
|
||||||
slot_code,
|
slot_code,
|
||||||
c.part_no or "",
|
c.part_no or "",
|
||||||
|
standardization["short_label"],
|
||||||
c.name or "",
|
c.name or "",
|
||||||
spec_fields["brand"],
|
spec_fields["brand"],
|
||||||
spec_fields["package"],
|
spec_fields["package"],
|
||||||
@@ -2108,6 +2748,7 @@ def export_box_labels_csv(box_id: int):
|
|||||||
note_fields["arrange"],
|
note_fields["arrange"],
|
||||||
note_fields["min_pack"],
|
note_fields["min_pack"],
|
||||||
c.specification or "",
|
c.specification or "",
|
||||||
|
", ".join(standardization["keywords"]),
|
||||||
int(c.quantity or 0),
|
int(c.quantity or 0),
|
||||||
c.location or "",
|
c.location or "",
|
||||||
c.note or "",
|
c.note or "",
|
||||||
@@ -2803,44 +3444,99 @@ def lcsc_import_to_edit_slot(box_id: int, slot: int):
|
|||||||
@app.route("/search")
|
@app.route("/search")
|
||||||
def search_page():
|
def search_page():
|
||||||
keyword = request.args.get("q", "").strip()
|
keyword = request.args.get("q", "").strip()
|
||||||
|
fuzziness = _parse_search_fuzziness(request.args.get("fuzziness", "balanced"))
|
||||||
notice = request.args.get("notice", "").strip()
|
notice = request.args.get("notice", "").strip()
|
||||||
error = request.args.get("error", "").strip()
|
error = request.args.get("error", "").strip()
|
||||||
results = []
|
results = []
|
||||||
|
search_plan = None
|
||||||
|
search_parse_notice = ""
|
||||||
|
search_trace = None
|
||||||
|
|
||||||
if keyword:
|
if keyword:
|
||||||
raw_results = (
|
settings = _get_ai_settings()
|
||||||
Component.query.join(Box, Box.id == Component.box_id)
|
search_plan, search_parse_notice, search_trace = _build_search_plan(keyword, settings)
|
||||||
.filter(
|
if search_trace is None:
|
||||||
Component.is_enabled.is_(True),
|
search_trace = {}
|
||||||
db.or_(
|
search_trace["fuzziness"] = fuzziness
|
||||||
Component.part_no.ilike(f"%{keyword}%"),
|
search_trace["fuzziness_label"] = SEARCH_FUZZY_PROFILES.get(fuzziness, SEARCH_FUZZY_PROFILES["balanced"])["label"]
|
||||||
Component.name.ilike(f"%{keyword}%"),
|
enabled_components = Component.query.filter_by(is_enabled=True).order_by(Component.part_no.asc(), Component.name.asc()).all()
|
||||||
),
|
box_by_id = {box.id: box for box in Box.query.all()}
|
||||||
)
|
|
||||||
.order_by(Component.part_no.asc(), Component.name.asc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
for c in raw_results:
|
matched_rows = []
|
||||||
box = Box.query.get(c.box_id)
|
for c in enabled_components:
|
||||||
results.append(
|
match_info = _search_component_match_info(c, search_plan, fuzziness=fuzziness)
|
||||||
|
if not match_info["is_match"]:
|
||||||
|
continue
|
||||||
|
box = box_by_id.get(c.box_id)
|
||||||
|
matched_rows.append(
|
||||||
{
|
{
|
||||||
"component": c,
|
"component": c,
|
||||||
"box_name": box.name if box else f"盒 {c.box_id}",
|
"box_name": box.name if box else f"盒 {c.box_id}",
|
||||||
"slot_code": slot_code_for_box(box, c.slot_index) if box else str(c.slot_index),
|
"slot_code": slot_code_for_box(box, c.slot_index) if box else str(c.slot_index),
|
||||||
"edit_url": url_for("edit_component", box_id=c.box_id, slot=c.slot_index, q=keyword),
|
"edit_url": url_for("edit_component", box_id=c.box_id, slot=c.slot_index, q=keyword),
|
||||||
|
"match_summary": " / ".join(
|
||||||
|
{
|
||||||
|
"part_no": "料号",
|
||||||
|
"name": "名称",
|
||||||
|
"specification": "规格",
|
||||||
|
"note": "备注",
|
||||||
|
}.get(field, field)
|
||||||
|
for field in match_info["matched_fields"]
|
||||||
|
)
|
||||||
|
or "全文匹配",
|
||||||
|
"matched_terms": match_info["matched_terms"],
|
||||||
|
"match_score": match_info["score"],
|
||||||
|
"fuzzy_matches": match_info.get("fuzzy_matches", []),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
results = sorted(matched_rows, key=lambda row: (-row["match_score"], row["component"].part_no or "", row["component"].name or ""))
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"search.html",
|
"search.html",
|
||||||
keyword=keyword,
|
keyword=keyword,
|
||||||
|
fuzziness=fuzziness,
|
||||||
|
fuzziness_profiles=SEARCH_FUZZY_PROFILES,
|
||||||
results=results,
|
results=results,
|
||||||
|
search_plan=search_plan,
|
||||||
|
search_trace=search_trace,
|
||||||
|
search_parse_notice=search_parse_notice,
|
||||||
notice=notice,
|
notice=notice,
|
||||||
error=error,
|
error=error,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/ai/component-standardize", methods=["POST"])
|
||||||
|
def ai_component_standardize():
|
||||||
|
"""生成元件标签与备注标准化建议。
|
||||||
|
|
||||||
|
中文说明:该接口只返回建议,不会直接写库;用户在编辑页确认后再把建议回填到表单,
|
||||||
|
这样可以兼顾 AI 提效和人工把关。
|
||||||
|
"""
|
||||||
|
part_no = request.form.get("part_no", "").strip()
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
specification = request.form.get("specification", "").strip()
|
||||||
|
note = request.form.get("note", "").strip()
|
||||||
|
|
||||||
|
if not part_no and not name:
|
||||||
|
return {"ok": False, "message": "至少需要填写料号或名称后再生成标准化建议"}, 400
|
||||||
|
|
||||||
|
settings = _get_ai_settings()
|
||||||
|
suggestion, parse_notice = _build_component_standardization_suggestion(
|
||||||
|
part_no,
|
||||||
|
name,
|
||||||
|
specification,
|
||||||
|
note,
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"suggestion": suggestion,
|
||||||
|
"parse_notice": parse_notice,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.route("/ai/inbound-parse", methods=["POST"])
|
@app.route("/ai/inbound-parse", methods=["POST"])
|
||||||
def ai_inbound_parse():
|
def ai_inbound_parse():
|
||||||
"""AI 入库预处理接口。
|
"""AI 入库预处理接口。
|
||||||
@@ -3281,20 +3977,21 @@ def ai_settings_page():
|
|||||||
def quick_outbound(component_id: int):
|
def quick_outbound(component_id: int):
|
||||||
component = Component.query.get_or_404(component_id)
|
component = Component.query.get_or_404(component_id)
|
||||||
keyword = request.form.get("q", "").strip()
|
keyword = request.form.get("q", "").strip()
|
||||||
|
fuzziness = _parse_search_fuzziness(request.form.get("fuzziness", "balanced"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
amount = _parse_non_negative_int(request.form.get("amount", "0"), 0)
|
amount = _parse_non_negative_int(request.form.get("amount", "0"), 0)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return redirect(url_for("search_page", q=keyword, error="出库数量必须是大于等于 0 的整数"))
|
return redirect(url_for("search_page", q=keyword, fuzziness=fuzziness, error="出库数量必须是大于等于 0 的整数"))
|
||||||
|
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
return redirect(url_for("search_page", q=keyword, error="出库数量必须大于 0"))
|
return redirect(url_for("search_page", q=keyword, fuzziness=fuzziness, error="出库数量必须大于 0"))
|
||||||
|
|
||||||
if not component.is_enabled:
|
if not component.is_enabled:
|
||||||
return redirect(url_for("search_page", q=keyword, error="该元件已停用,不能出库"))
|
return redirect(url_for("search_page", q=keyword, fuzziness=fuzziness, error="该元件已停用,不能出库"))
|
||||||
|
|
||||||
if amount > int(component.quantity or 0):
|
if amount > int(component.quantity or 0):
|
||||||
return redirect(url_for("search_page", q=keyword, error="出库数量超过当前库存"))
|
return redirect(url_for("search_page", q=keyword, fuzziness=fuzziness, error="出库数量超过当前库存"))
|
||||||
|
|
||||||
component.quantity = int(component.quantity or 0) - amount
|
component.quantity = int(component.quantity or 0) - amount
|
||||||
box = Box.query.get(component.box_id)
|
box = Box.query.get(component.box_id)
|
||||||
@@ -3309,7 +4006,7 @@ def quick_outbound(component_id: int):
|
|||||||
|
|
||||||
slot_code = slot_code_for_box(box, component.slot_index) if box else str(component.slot_index)
|
slot_code = slot_code_for_box(box, component.slot_index) if box else str(component.slot_index)
|
||||||
notice = f"出库成功: {component.part_no} -{amount}({slot_code})"
|
notice = f"出库成功: {component.part_no} -{amount}({slot_code})"
|
||||||
return redirect(url_for("search_page", q=keyword, notice=notice))
|
return redirect(url_for("search_page", q=keyword, fuzziness=fuzziness, notice=notice))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/stats")
|
@app.route("/stats")
|
||||||
|
|||||||
@@ -955,6 +955,118 @@ input[type="checkbox"] {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-row select {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-examples {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip,
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: color-mix(in srgb, var(--card) 84%, var(--card-alt));
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 58%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--card-alt) 72%, var(--accent) 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-analysis {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-map,
|
||||||
|
.standardize-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-map div,
|
||||||
|
.standardize-grid > div {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px;
|
||||||
|
background: color-mix(in srgb, var(--card) 90%, var(--card-alt));
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-map p,
|
||||||
|
.standardize-grid p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuzzy-details {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuzzy-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuzzy-details ul {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-steps {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-block {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: color-mix(in srgb, var(--card) 88%, var(--card-alt));
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
font: 12px/1.5 Consolas, "Cascadia Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.search-outbound-form {
|
.search-outbound-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1035,6 +1147,16 @@ th {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-standardize-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.standardize-grid .full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.guide-list {
|
.guide-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
@@ -1182,6 +1304,11 @@ th {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-map,
|
||||||
|
.standardize-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.chart-row {
|
.chart-row {
|
||||||
grid-template-columns: 100px 1fr 52px;
|
grid-template-columns: 100px 1fr 52px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<header class="hero slim">
|
<header class="hero slim">
|
||||||
<div>
|
<div>
|
||||||
<h1>AI参数设置</h1>
|
<h1>AI参数设置</h1>
|
||||||
<p>在此修改硅基流动 API 和补货建议参数</p>
|
<p>在此统一配置 AI 补货、自然语言搜索、入库预处理与标签标准化参数</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="btn btn-light" href="{{ url_for('types_page') }}">返回仓库概览</a>
|
<a class="btn btn-light" href="{{ url_for('types_page') }}">返回仓库概览</a>
|
||||||
@@ -27,7 +27,8 @@
|
|||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<form class="form-grid" method="post">
|
<form class="form-grid" method="post">
|
||||||
<h2 class="full">AI补货建议参数</h2>
|
<h2 class="full">通用 AI 参数</h2>
|
||||||
|
<p class="hint full">以下参数会同时用于 AI 入库预处理、自然语言搜索、重复巡检、补货建议、标签与备注标准化。</p>
|
||||||
<label>
|
<label>
|
||||||
API URL *
|
API URL *
|
||||||
<input type="text" name="api_url" required value="{{ settings.api_url }}" placeholder="https://api.siliconflow.cn/v1/chat/completions">
|
<input type="text" name="api_url" required value="{{ settings.api_url }}" placeholder="https://api.siliconflow.cn/v1/chat/completions">
|
||||||
|
|||||||
@@ -36,15 +36,15 @@
|
|||||||
<input type="hidden" name="q" value="{{ search_query or '' }}">
|
<input type="hidden" name="q" value="{{ search_query or '' }}">
|
||||||
<label>
|
<label>
|
||||||
料号 *
|
料号 *
|
||||||
<input type="text" name="part_no" required value="{{ component.part_no if component else '' }}" aria-label="料号" placeholder="如 STM32F103C8T6">
|
<input id="part-no-input" type="text" name="part_no" required value="{{ component.part_no if component else '' }}" aria-label="料号" placeholder="如 STM32F103C8T6">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
名称 *
|
名称 *
|
||||||
<input type="text" name="name" required value="{{ component.name if component else '' }}" aria-label="名称" placeholder="如 MCU STM32F103C8T6">
|
<input id="name-input" type="text" name="name" required value="{{ component.name if component else '' }}" aria-label="名称" placeholder="如 MCU STM32F103C8T6">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
规格
|
规格
|
||||||
<input type="text" name="specification" value="{{ component.specification if component else '' }}" placeholder="如 Cortex-M3 / LQFP-48">
|
<input id="specification-input" type="text" name="specification" value="{{ component.specification if component else '' }}" placeholder="如 Cortex-M3 / LQFP-48">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
数量
|
数量
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="full">
|
<label class="full">
|
||||||
备注
|
备注
|
||||||
<textarea name="note" rows="3" placeholder="如 LCSC item 9243">{{ component.note if component else '' }}</textarea>
|
<textarea id="note-input" name="note" rows="3" placeholder="如 LCSC item 9243">{{ component.note if component else '' }}</textarea>
|
||||||
</label>
|
</label>
|
||||||
<label class="full">
|
<label class="full">
|
||||||
<input type="checkbox" name="confirm_merge" value="1">
|
<input type="checkbox" name="confirm_merge" value="1">
|
||||||
@@ -82,6 +82,42 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="entry-sidebar">
|
<aside class="entry-sidebar">
|
||||||
|
<section class="panel quick-inbound-panel">
|
||||||
|
<h2>AI 标签与备注标准化</h2>
|
||||||
|
<p class="hint">生成更适合标签打印的短名称,并自动补全统一搜索关键词。确认后再回填到表单。</p>
|
||||||
|
<p class="hint" id="standardize-status" aria-live="polite"></p>
|
||||||
|
<section class="ai-standardize-preview" id="standardize-preview" hidden>
|
||||||
|
<div class="standardize-grid">
|
||||||
|
<div>
|
||||||
|
<strong>短标签</strong>
|
||||||
|
<p id="standardize-short-label">-</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>建议名称</strong>
|
||||||
|
<p id="standardize-name">-</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>建议规格</strong>
|
||||||
|
<p id="standardize-specification">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="full">
|
||||||
|
<strong>建议备注</strong>
|
||||||
|
<p id="standardize-note">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="full">
|
||||||
|
<strong>搜索关键词</strong>
|
||||||
|
<div class="match-tags" id="standardize-keywords"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" type="button" id="apply-standardization-btn">应用到表单</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-light" type="button" id="generate-standardization-btn">生成标准化建议</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel quick-inbound-panel">
|
<section class="panel quick-inbound-panel">
|
||||||
<h2>立创编号入库</h2>
|
<h2>立创编号入库</h2>
|
||||||
<p class="hint">当前编辑位置: {{ slot_code }}。仅支持粘贴立创商品详情页链接,系统会自动提取 itemId 并查询。</p>
|
<p class="hint">当前编辑位置: {{ slot_code }}。仅支持粘贴立创商品详情页链接,系统会自动提取 itemId 并查询。</p>
|
||||||
@@ -126,5 +162,104 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var partNoInput = document.getElementById('part-no-input');
|
||||||
|
var nameInput = document.getElementById('name-input');
|
||||||
|
var specificationInput = document.getElementById('specification-input');
|
||||||
|
var noteInput = document.getElementById('note-input');
|
||||||
|
var generateBtn = document.getElementById('generate-standardization-btn');
|
||||||
|
var applyBtn = document.getElementById('apply-standardization-btn');
|
||||||
|
var status = document.getElementById('standardize-status');
|
||||||
|
var preview = document.getElementById('standardize-preview');
|
||||||
|
var shortLabelNode = document.getElementById('standardize-short-label');
|
||||||
|
var nameNode = document.getElementById('standardize-name');
|
||||||
|
var specificationNode = document.getElementById('standardize-specification');
|
||||||
|
var noteNode = document.getElementById('standardize-note');
|
||||||
|
var keywordNode = document.getElementById('standardize-keywords');
|
||||||
|
var latestSuggestion = null;
|
||||||
|
|
||||||
|
if (!partNoInput || !nameInput || !specificationInput || !noteInput || !generateBtn || !applyBtn || !status || !preview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSuggestion(suggestion) {
|
||||||
|
latestSuggestion = suggestion;
|
||||||
|
preview.hidden = false;
|
||||||
|
shortLabelNode.textContent = suggestion.short_label || '-';
|
||||||
|
nameNode.textContent = suggestion.name || '-';
|
||||||
|
specificationNode.textContent = suggestion.specification || '-';
|
||||||
|
noteNode.textContent = suggestion.note || '-';
|
||||||
|
keywordNode.innerHTML = (suggestion.keywords || []).map(function (keyword) {
|
||||||
|
return '<span class="tag">' + escapeHtml(keyword) + '</span>';
|
||||||
|
}).join('') || '<span class="tag">-</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
generateBtn.addEventListener('click', function () {
|
||||||
|
if (!partNoInput.value.trim() && !nameInput.value.trim()) {
|
||||||
|
status.textContent = '请先填写料号或名称';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateBtn.disabled = true;
|
||||||
|
status.textContent = '正在生成标准化建议...';
|
||||||
|
|
||||||
|
var payload = new URLSearchParams();
|
||||||
|
payload.set('part_no', partNoInput.value || '');
|
||||||
|
payload.set('name', nameInput.value || '');
|
||||||
|
payload.set('specification', specificationInput.value || '');
|
||||||
|
payload.set('note', noteInput.value || '');
|
||||||
|
|
||||||
|
fetch('{{ url_for('ai_component_standardize') }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||||
|
},
|
||||||
|
body: payload.toString()
|
||||||
|
}).then(function (resp) {
|
||||||
|
return resp.json().then(function (data) {
|
||||||
|
if (!resp.ok || !data.ok) {
|
||||||
|
throw new Error(data.message || '生成失败');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}).then(function (data) {
|
||||||
|
renderSuggestion(data.suggestion || {});
|
||||||
|
status.textContent = data.parse_notice || '标准化建议已生成,可先预览再应用';
|
||||||
|
}).catch(function (error) {
|
||||||
|
status.textContent = '生成失败: ' + error.message;
|
||||||
|
}).finally(function () {
|
||||||
|
generateBtn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
applyBtn.addEventListener('click', function () {
|
||||||
|
if (!latestSuggestion) {
|
||||||
|
status.textContent = '请先生成标准化建议';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestSuggestion.name) {
|
||||||
|
nameInput.value = latestSuggestion.name;
|
||||||
|
}
|
||||||
|
if (latestSuggestion.specification) {
|
||||||
|
specificationInput.value = latestSuggestion.specification;
|
||||||
|
}
|
||||||
|
if (latestSuggestion.note) {
|
||||||
|
noteInput.value = latestSuggestion.note;
|
||||||
|
}
|
||||||
|
status.textContent = '建议已回填到表单,确认无误后再保存';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<header class="hero slim">
|
<header class="hero slim">
|
||||||
<div>
|
<div>
|
||||||
<h1>快速搜索</h1>
|
<h1>快速搜索</h1>
|
||||||
<p>按料号或名称搜索,点击可跳转到对应位置并直接出库</p>
|
<p>支持自然语言搜索,自动映射到料号、名称、规格和备注组合查询</p>
|
||||||
</div>
|
</div>
|
||||||
<nav class="hero-actions">
|
<nav class="hero-actions">
|
||||||
<a class="btn btn-light" href="{{ url_for('index') }}">返回首页</a>
|
<a class="btn btn-light" href="{{ url_for('index') }}">返回首页</a>
|
||||||
@@ -28,12 +28,57 @@
|
|||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<form id="search-form" method="get" action="{{ url_for('search_page') }}" class="search-row">
|
<form id="search-form" method="get" action="{{ url_for('search_page') }}" class="search-row">
|
||||||
<input id="search-input" type="search" name="q" placeholder="输入料号或名称" value="{{ keyword }}" aria-label="搜索关键字">
|
<input id="search-input" type="search" name="q" placeholder="如 3.3V 稳压芯片、0805 常用电阻、USB 相关器件" value="{{ keyword }}" aria-label="搜索关键字">
|
||||||
|
<select id="fuzziness-select" name="fuzziness" aria-label="匹配宽松度">
|
||||||
|
{% for key, profile in fuzziness_profiles.items() %}
|
||||||
|
<option value="{{ key }}" {% if fuzziness == key %}selected{% endif %}>{{ profile.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
<button class="btn" type="submit">搜索</button>
|
<button class="btn" type="submit">搜索</button>
|
||||||
</form>
|
</form>
|
||||||
|
<div class="search-examples">
|
||||||
|
<button class="chip" type="button" data-example="3.3V 稳压芯片">3.3V 稳压芯片</button>
|
||||||
|
<button class="chip" type="button" data-example="0805 常用电阻">0805 常用电阻</button>
|
||||||
|
<button class="chip" type="button" data-example="USB 相关器件">USB 相关器件</button>
|
||||||
|
</div>
|
||||||
<p class="hint">出库只需要输入数量,系统会自动扣减库存并记录统计。</p>
|
<p class="hint">出库只需要输入数量,系统会自动扣减库存并记录统计。</p>
|
||||||
|
<p class="hint">当前宽松度: {{ fuzziness_profiles[fuzziness].label }}(严格更精准,宽松更容易召回)</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if search_plan %}
|
||||||
|
<section class="panel search-analysis">
|
||||||
|
<div class="group-title-row">
|
||||||
|
<h2>搜索解析</h2>
|
||||||
|
<div class="actions">
|
||||||
|
<span class="hint">模式: {{ 'AI解析' if search_plan.mode == 'ai' else '规则解析' }}</span>
|
||||||
|
<button class="btn btn-light" type="button" id="show-search-trace">查看AI过程</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">{{ search_plan.summary }}</p>
|
||||||
|
{% if search_parse_notice %}
|
||||||
|
<p class="notice">{{ search_parse_notice }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="search-map">
|
||||||
|
<div>
|
||||||
|
<strong>料号</strong>
|
||||||
|
<p>{{ ' / '.join(search_plan.field_map.part_no) if search_plan.field_map.part_no else '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>名称</strong>
|
||||||
|
<p>{{ ' / '.join(search_plan.field_map.name) if search_plan.field_map.name else '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>规格</strong>
|
||||||
|
<p>{{ ' / '.join(search_plan.field_map.specification) if search_plan.field_map.specification else '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>备注</strong>
|
||||||
|
<p>{{ ' / '.join(search_plan.field_map.note) if search_plan.field_map.note else '-' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>搜索结果</h2>
|
<h2>搜索结果</h2>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -45,6 +90,7 @@
|
|||||||
<th>规格</th>
|
<th>规格</th>
|
||||||
<th>库存</th>
|
<th>库存</th>
|
||||||
<th>位置</th>
|
<th>位置</th>
|
||||||
|
<th>匹配说明</th>
|
||||||
<th>跳转</th>
|
<th>跳转</th>
|
||||||
<th>出库</th>
|
<th>出库</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -58,10 +104,32 @@
|
|||||||
<td>{{ c.specification or '-' }}</td>
|
<td>{{ c.specification or '-' }}</td>
|
||||||
<td>{{ c.quantity }}</td>
|
<td>{{ c.quantity }}</td>
|
||||||
<td>{{ row.box_name }} / {{ row.slot_code }}</td>
|
<td>{{ row.box_name }} / {{ row.slot_code }}</td>
|
||||||
|
<td>
|
||||||
|
<div>{{ row.match_summary }}</div>
|
||||||
|
<div class="hint">综合分: {{ '%.1f'|format(row.match_score) }}</div>
|
||||||
|
{% if row.matched_terms %}
|
||||||
|
<div class="match-tags">
|
||||||
|
{% for term in row.matched_terms %}
|
||||||
|
<span class="tag">{{ term }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if row.fuzzy_matches %}
|
||||||
|
<details class="fuzzy-details">
|
||||||
|
<summary>模糊命中详情</summary>
|
||||||
|
<ul>
|
||||||
|
{% for item in row.fuzzy_matches %}
|
||||||
|
<li>{{ item.term }} ({{ item.score }})</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td><a class="btn btn-light" href="{{ row.edit_url }}">进入位置</a></td>
|
<td><a class="btn btn-light" href="{{ row.edit_url }}">进入位置</a></td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="{{ url_for('quick_outbound', component_id=c.id) }}" class="search-outbound-form">
|
<form method="post" action="{{ url_for('quick_outbound', component_id=c.id) }}" class="search-outbound-form">
|
||||||
<input type="hidden" name="q" value="{{ keyword }}">
|
<input type="hidden" name="q" value="{{ keyword }}">
|
||||||
|
<input type="hidden" name="fuzziness" value="{{ fuzziness }}">
|
||||||
<input type="number" name="amount" min="1" step="1" placeholder="数量" required class="outbound-amount">
|
<input type="number" name="amount" min="1" step="1" placeholder="数量" required class="outbound-amount">
|
||||||
<button class="btn" type="submit">出库</button>
|
<button class="btn" type="submit">出库</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -69,13 +137,47 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7">{% if keyword %}未找到匹配元件{% else %}先输入关键字进行搜索{% endif %}</td>
|
<td colspan="8">{% if keyword %}未找到匹配元件{% else %}先输入关键字进行搜索{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="search-trace-modal" hidden>
|
||||||
|
<div class="modal-card panel" role="dialog" aria-modal="true" aria-labelledby="search-trace-title">
|
||||||
|
<div class="group-title-row">
|
||||||
|
<h2 id="search-trace-title">AI 搜索工作过程</h2>
|
||||||
|
<button class="btn btn-light" type="button" id="close-search-trace">关闭</button>
|
||||||
|
</div>
|
||||||
|
{% if search_trace %}
|
||||||
|
<ol class="trace-steps">
|
||||||
|
<li>收到自然语言输入: {{ search_trace.query }}</li>
|
||||||
|
<li>规则拆分候选字段,生成 fallback 计划</li>
|
||||||
|
<li>{% if search_trace.used_ai %}调用 AI 解析并返回字段映射{% else %}未调用 AI(参数未配置){% endif %}</li>
|
||||||
|
<li>{% if search_trace.used_fallback %}最终回退规则计划{% else %}最终采用 AI 计划{% endif %}</li>
|
||||||
|
<li>对每条库存记录执行多字段模糊评分并排序</li>
|
||||||
|
<li>当前宽松度: {{ search_trace.fuzziness_label if search_trace.fuzziness_label else '-' }}</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{% if search_trace.ai_error %}
|
||||||
|
<p class="alert">AI 错误: {{ search_trace.ai_error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h3>最终计划</h3>
|
||||||
|
<pre class="trace-block">{{ search_plan|tojson(indent=2) }}</pre>
|
||||||
|
<h3>规则兜底计划</h3>
|
||||||
|
<pre class="trace-block">{{ search_trace.fallback_plan|tojson(indent=2) }}</pre>
|
||||||
|
{% if search_trace.ai_raw %}
|
||||||
|
<h3>AI 原始返回</h3>
|
||||||
|
<pre class="trace-block">{{ search_trace.ai_raw }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="hint">当前没有可展示的过程数据。</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -92,6 +194,16 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-example]').forEach(function (button) {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
if (!searchInput || !searchForm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchInput.value = button.getAttribute('data-example') || '';
|
||||||
|
searchForm.requestSubmit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.outbound-amount').forEach(function (input) {
|
document.querySelectorAll('.outbound-amount').forEach(function (input) {
|
||||||
input.addEventListener('keydown', function (event) {
|
input.addEventListener('keydown', function (event) {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
@@ -103,6 +215,42 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var traceOpenBtn = document.getElementById('show-search-trace');
|
||||||
|
var traceCloseBtn = document.getElementById('close-search-trace');
|
||||||
|
var traceModal = document.getElementById('search-trace-modal');
|
||||||
|
function closeTraceModal() {
|
||||||
|
if (!traceModal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
traceModal.hidden = true;
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (traceOpenBtn && traceModal) {
|
||||||
|
traceOpenBtn.addEventListener('click', function () {
|
||||||
|
traceModal.hidden = false;
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (traceCloseBtn) {
|
||||||
|
traceCloseBtn.addEventListener('click', closeTraceModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (traceModal) {
|
||||||
|
traceModal.addEventListener('click', function (event) {
|
||||||
|
if (event.target === traceModal) {
|
||||||
|
closeTraceModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeTraceModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user