feat: 添加搜索功能的优先级选择、AI 开关和最低展示分数控制,优化用户体验

This commit is contained in:
2026-03-16 01:21:44 +08:00
parent dd47614d39
commit 5aa4ba6dc2
2 changed files with 158 additions and 3 deletions

104
app.py
View File

@@ -30,6 +30,7 @@ from logging.handlers import RotatingFileHandler
from flask import Flask, Response, redirect, render_template, request, session, url_for from flask import Flask, Response, redirect, render_template, request, session, url_for
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import or_, and_
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from werkzeug.security import check_password_hash, generate_password_hash 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: def _apply_box_type_overrides() -> None:
"""加载盒型覆盖配置。 """加载盒型覆盖配置。
@@ -2332,6 +2338,10 @@ def _build_rule_based_search_plan(query: str) -> dict:
if values: if values:
summary_bits.append(f"{field_labels[field]}: {' / '.join(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 { return {
"query": query, "query": query,
"mode": "rule", "mode": "rule",
@@ -5179,6 +5189,12 @@ def lcsc_import_to_edit_slot(box_id: int, slot: int):
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")) 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() notice = request.args.get("notice", "").strip()
error = request.args.get("error", "").strip() error = request.args.get("error", "").strip()
results = [] results = []
@@ -5186,21 +5202,101 @@ def search_page():
search_parse_notice = "" search_parse_notice = ""
search_trace = None 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: if keyword:
# 读取优先级控制name名称优先或 part_no料号优先
priority = (request.args.get("priority", "") or "").strip().lower() or "name"
settings = _get_ai_settings() 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: if search_trace is None:
search_trace = {} search_trace = {}
search_trace["fuzziness"] = fuzziness search_trace["fuzziness"] = fuzziness
search_trace["fuzziness_label"] = SEARCH_FUZZY_PROFILES.get(fuzziness, SEARCH_FUZZY_PROFILES["balanced"])["label"] 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_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()} box_by_id = {box.id: box for box in Box.query.all()}
matched_rows = [] matched_rows = []
for c in enabled_components: for c in enabled_components:
match_info = _search_component_match_info(c, search_plan, fuzziness=fuzziness) match_info = _search_component_match_info(c, search_plan, fuzziness=fuzziness)
# 过滤极低置信度的匹配,避免界面显示大量无关条目
if not match_info["is_match"]: if not match_info["is_match"]:
continue continue
if match_info.get("score", 0) < min_display_score:
continue
box = box_by_id.get(c.box_id) box = box_by_id.get(c.box_id)
matched_rows.append( matched_rows.append(
{ {
@@ -5237,6 +5333,8 @@ def search_page():
search_parse_notice=search_parse_notice, search_parse_notice=search_parse_notice,
notice=notice, notice=notice,
error=error, error=error,
min_display_score=min_display_score,
priority=priority if 'priority' in locals() else 'name',
) )

View File

@@ -35,6 +35,22 @@
<option value="{{ key }}" {% if fuzziness == key %}selected{% endif %}>{{ profile.label }}</option> <option value="{{ key }}" {% if fuzziness == key %}selected{% endif %}>{{ profile.label }}</option>
{% endfor %} {% endfor %}
</select> </select>
<label class="priority-label">优先级:
<select id="priority-select" name="priority" aria-label="搜索优先级">
<option value="name" {% if priority == 'name' %}selected{% endif %}>名称优先</option>
<option value="part_no" {% if priority == 'part_no' %}selected{% endif %}>料号优先</option>
</select>
</label>
<!-- AI 开关:用户可选择是否启用 AI 解析,状态保存在 localStorage -->
<input type="hidden" id="use-ai-hidden" name="use_ai" value="1">
<label class="ai-toggle">
<input type="checkbox" id="use-ai-toggle" checked>
启用 AI 解析
</label>
<!-- 最低展示分数控制(本地保存) -->
<label class="min-score-label">最低展示分数:
<input id="min-score-input" type="number" name="min_score" step="0.1" min="0" value="{{ min_display_score }}" style="width:80px">
</label>
<button class="btn" type="submit">搜索</button> <button class="btn" type="submit">搜索</button>
</form> </form>
<div class="search-examples"> <div class="search-examples">
@@ -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) { document.querySelectorAll('[data-example]').forEach(function (button) {
button.addEventListener('click', function () { button.addEventListener('click', function () {
if (!searchInput || !searchForm) { if (!searchInput || !searchForm) {