diff --git a/app.py b/app.py index 3c1cb77..cf95d19 100644 --- a/app.py +++ b/app.py @@ -30,6 +30,7 @@ from logging.handlers import RotatingFileHandler from flask import Flask, Response, redirect, render_template, request, session, url_for from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import or_, and_ from werkzeug.exceptions import HTTPException from werkzeug.security import check_password_hash, generate_password_hash @@ -339,6 +340,11 @@ SEARCH_FUZZY_PROFILES = { }, } +# 搜索结果最低展示分数,低于该分数的匹配将被隐藏(可按需调整) +SEARCH_MIN_DISPLAY_SCORE = 3.0 +# 每次搜索最多评分的候选元件数量,避免对整个库逐条打分导致页面刷新变慢 +SEARCH_MAX_CANDIDATES = 800 + def _apply_box_type_overrides() -> None: """加载盒型覆盖配置。 @@ -2332,6 +2338,10 @@ def _build_rule_based_search_plan(query: str) -> dict: if values: summary_bits.append(f"{field_labels[field]}: {' / '.join(values)}") + # 保证 field_map 四个字段都为 list,防止模板渲染出错 + for k in ("part_no", "name", "specification", "note"): + if k not in field_map or not isinstance(field_map[k], list): + field_map[k] = [] return { "query": query, "mode": "rule", @@ -5179,6 +5189,12 @@ def lcsc_import_to_edit_slot(box_id: int, slot: int): def search_page(): keyword = request.args.get("q", "").strip() fuzziness = _parse_search_fuzziness(request.args.get("fuzziness", "balanced")) + # 从请求参数读取最小展示分数(allow override),否则使用全局常量 + min_score_raw = (request.args.get("min_score", "") or "").strip() + try: + min_display_score = float(min_score_raw) if min_score_raw else SEARCH_MIN_DISPLAY_SCORE + except (TypeError, ValueError): + min_display_score = SEARCH_MIN_DISPLAY_SCORE notice = request.args.get("notice", "").strip() error = request.args.get("error", "").strip() results = [] @@ -5186,21 +5202,101 @@ def search_page(): search_parse_notice = "" search_trace = None + # 解析是否启用 AI:支持通过 GET 参数 `use_ai=0/1` 控制, + # 如果未提供则根据是否配置 AI API 自动决定(已配置则默认开启)。 + use_ai_raw = (request.args.get("use_ai", "") or "").strip().lower() + if use_ai_raw in ("0", "false", "no", "off"): + use_ai = False + elif use_ai_raw in ("1", "true", "yes", "on"): + use_ai = True + else: + tmp_settings = _get_ai_settings() + use_ai = bool((tmp_settings.get("api_key") or "") and (tmp_settings.get("api_url") or "") and (tmp_settings.get("model") or "")) + if keyword: + # 读取优先级控制:name(名称优先)或 part_no(料号优先) + priority = (request.args.get("priority", "") or "").strip().lower() or "name" settings = _get_ai_settings() - search_plan, search_parse_notice, search_trace = _build_search_plan(keyword, settings) + if use_ai: + search_plan, search_parse_notice, search_trace = _build_search_plan(keyword, settings) + else: + # 用户显式关闭 AI,直接使用规则兜底计划 + fallback = _build_rule_based_search_plan(keyword) + search_plan = fallback + search_parse_notice = "" + search_trace = { + "query": keyword, + "used_ai": False, + "used_fallback": True, + "final_mode": "rule", + "fallback_plan": fallback, + "ai_raw": "", + "ai_error": "", + } + 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() + search_trace["fuzziness_label"] = SEARCH_FUZZY_PROFILES.get(fuzziness, SEARCH_FUZZY_PROFILES["balanced"])['label'] + # 只拉取一定数量候选项以加快页面响应;如需全库扫描可调大 SEARCH_MAX_CANDIDATES + # 非 AI 模式下使用 SQL 预筛选:对每个词生成 OR 子句(匹配 part_no/name/specification/note), + # 并用 AND 将各词串联,要求每个词至少在某个字段中出现以增强精确度。 + if not use_ai: + # 优先按字段顺序拉取候选:料号 > 名称 > 规格 > 备注 + terms = [t for t in _split_natural_language_terms(keyword) if t] + candidates = [] + matched_ids = set() + remaining = SEARCH_MAX_CANDIDATES + + # 根据 priority 参数设置字段搜索顺序 + if priority == "part_no": + fields_order = ("part_no", "name", "specification", "note") + else: + fields_order = ("name", "part_no", "specification", "note") + for field in fields_order: + if remaining <= 0: + break + if not terms: + break + like_clauses = [getattr(Component, field).ilike(f"%{term}%") for term in terms] + if not like_clauses: + continue + q = Component.query.filter(or_(*like_clauses), Component.is_enabled == True) + if matched_ids: + q = q.filter(~Component.id.in_(matched_ids)) + rows = q.order_by(Component.part_no.asc(), Component.name.asc()).limit(remaining).all() + for r in rows: + candidates.append(r) + matched_ids.add(r.id) + remaining = SEARCH_MAX_CANDIDATES - len(candidates) + + # 如果仍不足,补充其他已启用的组件(排除已选) + if remaining > 0: + q = Component.query.filter(Component.is_enabled == True) + if matched_ids: + q = q.filter(~Component.id.in_(matched_ids)) + rows = q.order_by(Component.part_no.asc(), Component.name.asc()).limit(remaining).all() + for r in rows: + candidates.append(r) + + enabled_components = candidates + else: + enabled_components = ( + Component.query.filter_by(is_enabled=True) + .order_by(Component.part_no.asc(), Component.name.asc()) + .limit(SEARCH_MAX_CANDIDATES) + .all() + ) box_by_id = {box.id: box for box in Box.query.all()} 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 + if match_info.get("score", 0) < min_display_score: + continue box = box_by_id.get(c.box_id) matched_rows.append( { @@ -5237,6 +5333,8 @@ def search_page(): search_parse_notice=search_parse_notice, notice=notice, error=error, + min_display_score=min_display_score, + priority=priority if 'priority' in locals() else 'name', ) diff --git a/templates/search.html b/templates/search.html index 0417470..b3d66ee 100644 --- a/templates/search.html +++ b/templates/search.html @@ -35,6 +35,22 @@ {% endfor %} + + + + + +
@@ -195,6 +211,47 @@ }); } + // AI 开关行为:从 localStorage 读取用户偏好并在提交时保存 + var useAiToggle = document.getElementById('use-ai-toggle'); + var useAiHidden = document.getElementById('use-ai-hidden'); + var minScoreInput = document.getElementById('min-score-input'); + var prioritySelect = document.getElementById('priority-select'); + try { + var saved = localStorage.getItem('use_ai_enabled'); + if (saved !== null) { + var enabled = saved === '1' || saved === 'true'; + if (useAiToggle) useAiToggle.checked = enabled; + if (useAiHidden) useAiHidden.value = enabled ? '1' : '0'; + } + var savedScore = localStorage.getItem('search_min_score'); + if (savedScore !== null && minScoreInput) { + minScoreInput.value = savedScore; + } + var savedPriority = localStorage.getItem('search_priority'); + if (savedPriority !== null && prioritySelect) { + try { prioritySelect.value = savedPriority; } catch (e) {} + } + } catch (e) { + // ignore localStorage errors + } + if (searchForm) { + searchForm.addEventListener('submit', function () { + var enabled = !!(useAiToggle && useAiToggle.checked); + if (useAiHidden) useAiHidden.value = enabled ? '1' : '0'; + try { + if (minScoreInput) { + localStorage.setItem('search_min_score', String(minScoreInput.value)); + } + if (prioritySelect) { + localStorage.setItem('search_priority', String(prioritySelect.value)); + } + } catch (e) {} + try { + localStorage.setItem('use_ai_enabled', enabled ? '1' : '0'); + } catch (e) {} + }); + } + document.querySelectorAll('[data-example]').forEach(function (button) { button.addEventListener('click', function () { if (!searchInput || !searchForm) {