diff --git a/wwwroot/knowledge_bases_list/search.ui b/wwwroot/knowledge_bases_list/search.ui index c52a217..46b8d1c 100644 --- a/wwwroot/knowledge_bases_list/search.ui +++ b/wwwroot/knowledge_bases_list/search.ui @@ -1,6 +1,6 @@ { "widgettype": "VBox", - "options": {"padding": "20px", "spacing": "16px", "width": "100%", "height": "100%", "css": "filler"}, + "options": {"padding": "20px", "spacing": "12px", "width": "100%", "height": "100%", "css": "filler"}, "subwidgets": [ {"widgettype": "Text", "options": {"text": "🔍 知识检索", "cfontsize": 22, "fontWeight": "bold", "color": "#1a1a2e"}}, {"widgettype": "Text", "options": {"text": "选择知识库,输入文字或上传文件检索", "cfontsize": 13, "color": "#888"}}, @@ -26,9 +26,24 @@ } ] }, + { + "widgettype": "HBox", + "options": {"spacing": "8px", "alignItems": "center", "wrap": true}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": "🏷 标签过滤:", "cfontsize": 12, "color": "#666", "padding": "2px 0"}}, + { + "widgettype": "HBox", + "id": "tag_filter", + "options": {"spacing": "4px", "alignItems": "center", "wrap": true, "padding": "2px 0"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": "选择知识库后加载标签", "cfontsize": 11, "color": "#ccc"}} + ] + } + ] + }, { "widgettype": "VBox", - "options": {"css": "filler", "marginTop": "16px", "width": "100%", "padding": "0", "spacing": "0"}, + "options": {"css": "filler", "marginTop": "4px", "width": "100%", "padding": "0", "spacing": "0"}, "subwidgets": [ { "widgettype": "VScrollPanel", @@ -42,18 +57,24 @@ } ], "binds": [ + { + "wid": "kb_selector", + "event": "change", + "actiontype": "script", + "script": "var kb_id=this.resultValue()||'all';var tf=document.querySelector('#tag_filter');if(!tf)return;if(kb_id==='all'){tf.innerHTML='选择具体知识库后加载标签';return};fetch('/rag/knowledge_bases_list/tag_options.dspy?_webbricks_=1&kb_id='+encodeURIComponent(kb_id)).then(function(r){return r.json()}).then(function(tags){tf.innerHTML='';if(!tags||tags.length===0){tf.innerHTML='该知识库暂无标签';return};tags.forEach(function(t){var chip=document.createElement('span');chip.setAttribute('data-tid',t.value);chip.setAttribute('data-sel','0');chip.style.cssText='display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;cursor:pointer;margin:2px 4px;border:1px solid #ddd;background:#f5f5f5;color:#666;transition:all .2s';chip.textContent='#'+t.text;chip.onclick=function(){var el=this;var on=el.getAttribute('data-sel')==='1';el.setAttribute('data-sel',on?'0':'1');el.style.background=on?'#f5f5f5':'#e3f2fd';el.style.color=on?'#666':'#1565c0';el.style.borderColor=on?'#ddd':'#90caf9';};tf.appendChild(chip)})}).catch(function(){tf.innerHTML='加载标签失败'})" + }, { "wid": "search_bar", "event": "search", "actiontype": "script", - "script": "var kw=event.params.keyword||'';var kb=bricks.getWidgetById('kb_selector',this);var kb_id=(kb&&kb.resultValue())||'all';var target=bricks.getWidgetById('search_results',this);if(target){bricks.buildUrlwidgetHandler(this,target,{},{options:{url:'/rag/knowledge_bases_list/search_result.dspy',params:{_webbricks_:1,kb_id:kb_id,keyword:kw}},mode:'replace'})()}" + "script": "var kw=event.params.keyword||'';var kb=bricks.getWidgetById('kb_selector',this);var kb_id=(kb&&kb.resultValue())||'all';var tag_ids='';var chips=document.querySelectorAll('#tag_filter [data-tid]');chips.forEach(function(c){if(c.getAttribute('data-sel')==='1'){tag_ids+=(tag_ids?',':'')+c.getAttribute('data-tid')}});var target=bricks.getWidgetById('search_results',this);if(target){bricks.buildUrlwidgetHandler(this,target,{},{options:{url:'/rag/knowledge_bases_list/search_result.dspy',params:{_webbricks_:1,kb_id:kb_id,keyword:kw,tag_ids:tag_ids}},mode:'replace'})()}" }, { "wid": "search_file", "event": "changed", "actiontype": "script", "target": "search_file", - "script": "var f=this.value;if(!f)return;if(Array.isArray(f))f=f[0];if(!f)return;var kb=bricks.getWidgetById('kb_selector',this.parent);var kb_id=(kb&&kb.resultValue())||'all';var fd=new FormData();fd.append('file',f);var u='/rag/knowledge_bases_list/search_result.dspy?_webbricks_=1&kb_id='+encodeURIComponent(kb_id)+'&file_name='+encodeURIComponent(f.name);var pv=this.parent.parent;fetch(u,{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){var fp=bricks.getWidgetById('search_results',pv);if(fp){fp.clear_widgets();bricks.widgetBuild(d,fp).then(function(w){if(w)fp.add_widget(w)})}})" + "script": "var f=this.value;if(!f)return;if(Array.isArray(f))f=f[0];if(!f)return;var kb=bricks.getWidgetById('kb_selector',this.parent);var kb_id=(kb&&kb.resultValue())||'all';var tag_ids='';var chips=document.querySelectorAll('#tag_filter [data-tid]');chips.forEach(function(c){if(c.getAttribute('data-sel')==='1'){tag_ids+=(tag_ids?',':'')+c.getAttribute('data-tid')}});var fd=new FormData();fd.append('file',f);var u='/rag/knowledge_bases_list/search_result.dspy?_webbricks_=1&kb_id='+encodeURIComponent(kb_id)+'&file_name='+encodeURIComponent(f.name)+'&tag_ids='+encodeURIComponent(tag_ids);var pv=this.parent.parent;fetch(u,{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){var fp=bricks.getWidgetById('search_results',pv);if(fp){fp.clear_widgets();bricks.widgetBuild(d,fp).then(function(w){if(w)fp.add_widget(w)})}})" } ] } diff --git a/wwwroot/knowledge_bases_list/search_result.dspy b/wwwroot/knowledge_bases_list/search_result.dspy index 4fbe5b8..6fd7610 100644 --- a/wwwroot/knowledge_bases_list/search_result.dspy +++ b/wwwroot/knowledge_bases_list/search_result.dspy @@ -2,6 +2,7 @@ ns = params_kw.copy() kb_id = ns.get('kb_id', '') env = request._run_ns query = ns.get("query") or ns.get("value") or ns.get("keyword") or ns.get("text") or "" +tag_ids_str = ns.get("tag_ids", "") # Defensive: legacy UI cached in browser double-encodes the keyword (%E5%88%86... arrives as-is). # Normal path: server already decoded once, no '%' present, this is a no-op. if "%" in query: @@ -14,7 +15,7 @@ if "%" in query: except Exception: pass info(f'[search_result] params_kw={params_kw}') -info(f'[search_result] parsed: kb_id={kb_id!r} query={query!r} method={request.method} url={request.url}') +info(f'[search_result] parsed: kb_id={kb_id!r} query={query!r} tag_ids={tag_ids_str!r} method={request.method} url={request.url}') file_data = None file_name = None @@ -42,6 +43,29 @@ if not query: ] }, ensure_ascii=False) +# 0. Parse tag filter — find doc_ids that match ALL selected tags +tag_doc_ids = None +tag_info = "" +if tag_ids_str: + wanted_tags = [t.strip() for t in tag_ids_str.split(",") if t.strip()] + if wanted_tags: + try: + async with get_sor_context(env, 'rag') as sor: + placeholders = [] + nsq = {"kb_id": kb_id} + for i, tid in enumerate(wanted_tags): + placeholders.append("${tid_" + str(i) + "}$") + nsq["tid_" + str(i)] = tid + sql = ("SELECT media_id FROM media_tags " + "WHERE kb_id=${kb_id}$ AND media_type='document' AND tag_id IN (" + ",".join(placeholders) + ") " + "GROUP BY media_id HAVING COUNT(DISTINCT tag_id)=" + str(len(wanted_tags))) + recs = await sor.sqlExe(sql, nsq) + tag_doc_ids = set(r.media_id for r in recs) + tag_info = " (标签过滤: " + ",".join(wanted_tags[:3]) + ("..." if len(wanted_tags)>3 else "") + ")" + info(f'[search_result] tag filter: {len(tag_doc_ids)} docs match {len(wanted_tags)} tags') + except Exception as e: + info(f'[search_result] tag filter error: {e}') + # 1. Embed vec = [] try: @@ -97,7 +121,6 @@ for row in raw_rows: doc_id = rid.rsplit('_c', 1)[0] if '_c' in rid else rid # Look up chunk text from DB chunk_text = '' - doc_name = '' async with get_sor_context(env, 'rag') as sor: recs = await sor.sqlExe( "SELECT content, doc_id FROM document_chunks WHERE id=${id}$", @@ -105,12 +128,17 @@ for row in raw_rows: if recs: chunk_text = recs[0].content or '' doc_id = recs[0].doc_id or doc_id + # Tag filter + if tag_doc_ids is not None and doc_id not in tag_doc_ids: + continue if chunk_text: is_kw = rid in kw_ids hits.append({"id": rid, "doc_id": doc_id, "score": 0.99 if is_kw else score, "text": chunk_text, "kw": is_kw}) seen.add(rid) for kr in kw_rows: if kr["id"] not in seen: + if tag_doc_ids is not None and kr["doc_id"] not in tag_doc_ids: + continue hits.append(kr) seen.add(kr["id"]) @@ -118,7 +146,7 @@ hits.sort(key=lambda x: x.get("score", 0), reverse=True) hits = hits[:top_k] subwidgets = [ - {"widgettype": "Text", "options": {"text": f"🔍 检索: {query}", "cfontsize": 18, "fontWeight": "bold", "marginBottom": "8px"}}, + {"widgettype": "Text", "options": {"text": f"🔍 检索: {query}{tag_info}", "cfontsize": 18, "fontWeight": "bold", "marginBottom": "8px"}}, {"widgettype": "Text", "options": {"text": f"共 {len(hits)} 条结果 (召回 {len(raw_rows)} 条)", "cfontsize": 13, "color": "#888", "marginBottom": "16px"}} ] diff --git a/wwwroot/knowledge_bases_list/tag_options.dspy b/wwwroot/knowledge_bases_list/tag_options.dspy new file mode 100644 index 0000000..d3d3b6e --- /dev/null +++ b/wwwroot/knowledge_bases_list/tag_options.dspy @@ -0,0 +1,12 @@ +import json +ns = params_kw.copy() +kb_id = ns.get('kb_id', '') +if not kb_id or kb_id == 'all': + return json.dumps([], ensure_ascii=False) +env = request._run_ns +async with get_sor_context(env, 'rag') as sor: + recs = await sor.sqlExe( + "SELECT id, name, color FROM tags WHERE kb_id=${kb_id}$ ORDER BY created_at", + {"kb_id": kb_id}) + items = [{"value": r.id, "text": r.name, "color": r.color or '#3b82f6'} for r in recs] + return json.dumps(items, ensure_ascii=False)