feat: 添加搜索功能的优先级选择、AI 开关和最低展示分数控制,优化用户体验
This commit is contained in:
104
app.py
104
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',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,22 @@
|
||||
<option value="{{ key }}" {% if fuzziness == key %}selected{% endif %}>{{ profile.label }}</option>
|
||||
{% endfor %}
|
||||
</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>
|
||||
</form>
|
||||
<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) {
|
||||
button.addEventListener('click', function () {
|
||||
if (!searchInput || !searchForm) {
|
||||
|
||||
Reference in New Issue
Block a user