feat: 添加搜索功能的优先级选择、AI 开关和最低展示分数控制,优化用户体验
This commit is contained in:
102
app.py
102
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 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()
|
||||||
|
if use_ai:
|
||||||
search_plan, search_parse_notice, search_trace = _build_search_plan(keyword, settings)
|
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',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user