diff --git a/README.md b/README.md index c032d4c..d76410d 100644 --- a/README.md +++ b/README.md @@ -108,10 +108,12 @@ $env:SILICONFLOW_MODEL="Qwen/Qwen2.5-7B-Instruct" - `28格/14格/自定义容器` 支持立创编号入库:进入对应格位编辑页后输入编号,自动拉取商品基础信息并写入当前格位。 - 支持按当前盒子导出打标 CSV(仅导出启用记录),可用于热敏打标机导入。 - 打标 CSV 列名为中英双语格式(如 `料号(part_no)`、`备注(note)`),便于直接识别。 +- 打标 CSV 新增 `短标签(short_label)` 与 `搜索关键词(search_keywords)` 列,便于打标和后续检索。 ### 3.3 编辑页 `/edit//` - 编辑料号、名称、规格、数量、备注。 +- 新增 `AI 标签与备注标准化`:可生成更适合标签打印的短标签、建议名称、建议备注和搜索关键词,确认后再回填表单。 - 通过按钮启用/停用。 - 可删除当前格子记录。 @@ -129,7 +131,8 @@ $env:SILICONFLOW_MODEL="Qwen/Qwen2.5-7B-Instruct" ### 3.5 快速搜索与出库 `/search` -- 支持按 `料号` 或 `名称` 搜索已启用元件。 +- 支持自然语言搜索,例如 `3.3V 稳压芯片`、`0805 常用电阻`、`USB 相关器件`。 +- 会自动把搜索词映射到 `料号 / 名称 / 规格 / 备注` 组合搜索,并显示解析结果。 - 搜索结果可一键跳转到对应盒位编辑页。 - 支持快速出库:只填写数量即可扣减库存,并写入统计日志。 @@ -141,6 +144,7 @@ $env:SILICONFLOW_MODEL="Qwen/Qwen2.5-7B-Instruct" ### 3.7 AI 参数设置 `/ai/settings` - 支持页面内编辑:`API URL / 模型名称 / API Key / 超时 / 低库存阈值 / 建议条目上限`。 +- 同一套 AI 参数同时用于:入库预处理、自然语言搜索、重复巡检、补货建议、标签与备注标准化。 - 支持页面内编辑立创接口参数:`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 自然语言搜索 -- [ ] 支持搜索“3.3V 稳压芯片”“0805 常用电阻”“USB 相关器件”这类自然语言 -- [ ] 将自然语言自动映射到 `名称 / 规格 / 备注 / 料号` 的组合搜索 +- [x] AI 自然语言搜索 +- [x] 支持搜索“3.3V 稳压芯片”“0805 常用电阻”“USB 相关器件”这类自然语言 +- [x] 将自然语言自动映射到 `名称 / 规格 / 备注 / 料号` 的组合搜索 -- [ ] AI 标签与备注标准化 -- [ ] 自动生成更适合标签打印的短名称 -- [ ] 自动补全更统一的备注格式和搜索关键词 -- [ ] 让名称更短、备注更规范,方便后续检索和盘点 +- [x] AI 标签与备注标准化 +- [x] 自动生成更适合标签打印的短名称 +- [x] 自动补全更统一的备注格式和搜索关键词 +- [x] 让名称更短、备注更规范,方便后续检索和盘点 ### 第五阶段:做更深层的数据分析 diff --git a/app.py b/app.py index 96f4059..22bc6dd 100644 --- a/app.py +++ b/app.py @@ -11,6 +11,7 @@ import re import csv import json import hmac +import difflib import base64 import random import string @@ -94,6 +95,83 @@ 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: """加载盒型覆盖配置。 @@ -1515,6 +1593,564 @@ def _pick_standard_text(values: list[str]) -> str: 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: box = box_by_id.get(component.box_id) lcsc_code = _extract_lcsc_code_from_text(component.note or "") @@ -2073,6 +2709,7 @@ def export_box_labels_csv(box_id: int): "盒子名称(box_name)", "位置编号(slot_code)", "料号(part_no)", + "短标签(short_label)", "名称(name)", "品牌(brand)", "封装(package)", @@ -2082,6 +2719,7 @@ def export_box_labels_csv(box_id: int): "商品编排(arrange)", "最小包装(min_pack)", "规格(specification)", + "搜索关键词(search_keywords)", "数量(quantity)", "位置备注(location)", "备注(note)", @@ -2092,6 +2730,7 @@ def export_box_labels_csv(box_id: int): slot_code = slot_code_for_box(box, c.slot_index) spec_fields = _parse_slot_spec_fields(c.specification) 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"]: note_fields["lcsc_code"] = _extract_lcsc_code_from_text(c.part_no) writer.writerow( @@ -2099,6 +2738,7 @@ def export_box_labels_csv(box_id: int): box.name, slot_code, c.part_no or "", + standardization["short_label"], c.name or "", spec_fields["brand"], spec_fields["package"], @@ -2108,6 +2748,7 @@ def export_box_labels_csv(box_id: int): note_fields["arrange"], note_fields["min_pack"], c.specification or "", + ", ".join(standardization["keywords"]), int(c.quantity or 0), c.location or "", c.note or "", @@ -2803,44 +3444,99 @@ def lcsc_import_to_edit_slot(box_id: int, slot: int): @app.route("/search") def search_page(): keyword = request.args.get("q", "").strip() + fuzziness = _parse_search_fuzziness(request.args.get("fuzziness", "balanced")) notice = request.args.get("notice", "").strip() error = request.args.get("error", "").strip() results = [] + search_plan = None + search_parse_notice = "" + search_trace = None if keyword: - raw_results = ( - Component.query.join(Box, Box.id == Component.box_id) - .filter( - Component.is_enabled.is_(True), - db.or_( - Component.part_no.ilike(f"%{keyword}%"), - Component.name.ilike(f"%{keyword}%"), - ), - ) - .order_by(Component.part_no.asc(), Component.name.asc()) - .all() - ) + settings = _get_ai_settings() + search_plan, search_parse_notice, search_trace = _build_search_plan(keyword, settings) + if search_trace is None: + search_trace = {} + search_trace["fuzziness"] = fuzziness + search_trace["fuzziness_label"] = SEARCH_FUZZY_PROFILES.get(fuzziness, SEARCH_FUZZY_PROFILES["balanced"])["label"] + 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()} - for c in raw_results: - box = Box.query.get(c.box_id) - results.append( + matched_rows = [] + for c in enabled_components: + 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, "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), "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( "search.html", keyword=keyword, + fuzziness=fuzziness, + fuzziness_profiles=SEARCH_FUZZY_PROFILES, results=results, + search_plan=search_plan, + search_trace=search_trace, + search_parse_notice=search_parse_notice, notice=notice, 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"]) def ai_inbound_parse(): """AI 入库预处理接口。 @@ -3281,20 +3977,21 @@ def ai_settings_page(): def quick_outbound(component_id: int): component = Component.query.get_or_404(component_id) keyword = request.form.get("q", "").strip() + fuzziness = _parse_search_fuzziness(request.form.get("fuzziness", "balanced")) try: amount = _parse_non_negative_int(request.form.get("amount", "0"), 0) 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: - 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: - 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): - 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 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) 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") diff --git a/static/css/style.css b/static/css/style.css index 7828ee9..dafc59a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -955,6 +955,118 @@ input[type="checkbox"] { 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 { display: flex; gap: 8px; @@ -1035,6 +1147,16 @@ th { 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 { margin: 0; padding-left: 18px; @@ -1182,6 +1304,11 @@ th { grid-template-columns: 1fr; } + .search-map, + .standardize-grid { + grid-template-columns: 1fr; + } + .chart-row { grid-template-columns: 100px 1fr 52px; } diff --git a/templates/ai_settings.html b/templates/ai_settings.html index 387828c..48702cd 100644 --- a/templates/ai_settings.html +++ b/templates/ai_settings.html @@ -10,7 +10,7 @@

AI参数设置

-

在此修改硅基流动 API 和补货建议参数

+

在此统一配置 AI 补货、自然语言搜索、入库预处理与标签标准化参数

返回仓库概览 @@ -27,7 +27,8 @@
-

AI补货建议参数

+

通用 AI 参数

+

以下参数会同时用于 AI 入库预处理、自然语言搜索、重复巡检、补货建议、标签与备注标准化。

+ diff --git a/templates/search.html b/templates/search.html index 747089a..81a1ed6 100644 --- a/templates/search.html +++ b/templates/search.html @@ -10,7 +10,7 @@

快速搜索

-

按料号或名称搜索,点击可跳转到对应位置并直接出库

+

支持自然语言搜索,自动映射到料号、名称、规格和备注组合查询