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 ""
# 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:
    try:
        from urllib.parse import unquote
        d = unquote(query)
        if d != query:
            info(f'[search_result] unquote fallback: {query!r} -> {d!r}')
            query = d
    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}')

file_data = None
file_name = None
try:
    reader = await request.multipart()
    while True:
        part = await reader.next()
        if part is None: break
        if part.name == "file": file_data = await part.read(); file_name = part.filename; break
except: pass
if not file_data:
    try: file_data = await request.read();
    except: pass
    if file_data and len(file_data) < 10: file_data = None

import base64, io
top_k = int(ns.get('top_k', 5))

if not query:
    return json.dumps({
        "widgettype": "VBox", "options": {"padding": "20px", "spacing": "16px"},
        "subwidgets": [
            {"widgettype": "Text", "options": {"text": "🔍 知识检索", "cfontsize": 20, "fontWeight": "bold"}},
            {"widgettype": "Text", "options": {"text": "请输入检索内容", "color": "#666", "marginTop": "20px"}}
        ]
    }, ensure_ascii=False)

# 1. Embed
vec = []
try:
    client = StreamHttpClient()
    resp = await client.request('POST', 'https://embedding.opencomputing.net:10443/api/embed',
        json={"texts": [query], "model": "CLIP-ViT-H-14"})
    emb = json.loads(resp)
    vec = emb.get("text_embeddings", emb.get("embeddings", [[]]))[0]
except:
    pass
if not vec:
    return json.dumps({"widgettype": "Text", "options": {"text": "向量化失败", "cfontsize": 14, "color": "#e74c3c"}}, ensure_ascii=False)

# 2. VDB search
raw_rows = []
try:
    recall_n = top_k * 3
    client2 = StreamHttpClient()
    resp2 = await client2.request('POST', 'https://vectordb.opencomputing.net:10443/v1/query',
        json={"colname": kb_id, "vector": vec, "pagerows": recall_n, "output_fields": ["*"]})
    vdb = json.loads(resp2)
    raw_rows = vdb.get("data", {}).get("rows", [])
    if not isinstance(raw_rows, list):
        raw_rows = []
except:
    pass

# 3. Keyword recall (hybrid search: vector alone misses technical terms)
kw_ids = set()
kw_rows = []
try:
    tokens = [t for t in query.split() if t][:5] or [query]
    conds = []
    nsq = {"kb_id": kb_id}
    for i, t in enumerate(tokens):
        conds.append("content LIKE ${kw_" + str(i) + "}$")
        nsq["kw_" + str(i)] = "%" + t + "%"
    ksql = "SELECT id, doc_id, content FROM document_chunks WHERE kb_id=${kb_id}$ AND (" + " OR ".join(conds) + ") LIMIT 20"
    async with get_sor_context(env, 'rag') as sor:
        krecs = await sor.sqlExe(ksql, nsq)
    for r in krecs:
        kw_ids.add(r.id)
        kw_rows.append({"id": r.id, "doc_id": r.doc_id or "", "score": 0.99, "text": r.content or '', "kw": True})
except Exception as e:
    info('[search_result] keyword recall failed: %s' % e)

hits = []
seen = set()
for row in raw_rows:
    rid = str(row.get("id", ""))
    score = row.get("score", 0)
    # Resolve doc_id from chunk id pattern "xxxx_c0"
    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}$",
            {"id": rid})
        if recs:
            chunk_text = recs[0].content or ''
            doc_id = recs[0].doc_id or doc_id
    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:
        hits.append(kr)
        seen.add(kr["id"])

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"共 {len(hits)} 条结果 (召回 {len(raw_rows)} 条)", "cfontsize": 13, "color": "#888", "marginBottom": "16px"}}
]

if not hits:
    subwidgets.append({"widgettype": "Text", "options": {"text": "😔 未找到相关内容", "cfontsize": 14, "color": "#aaa", "halign": "center", "marginTop": "40px"}})
else:
    for i, h in enumerate(hits):
        score_pct = round(float(h["score"]) * 100, 1)
        color = "#3b82f6" if score_pct > 60 else ("#10b981" if score_pct > 30 else "#f59e0b")
        badges = [{"widgettype": "Text", "options": {"text": f"{score_pct}%", "cfontsize": 11, "bgcolor": color, "color": "#fff", "padding": "2px 8px", "borderRadius": "10px"}}]
        if h.get("kw"):
            badges.append({"widgettype": "Text", "options": {"text": "📌 关键词命中", "cfontsize": 11, "bgcolor": "#f59e0b", "color": "#fff", "padding": "2px 8px", "borderRadius": "10px", "marginLeft": "6px"}})
        subwidgets.append({
            "widgettype": "VBox",
            "options": {"padding": "12px 16px", "marginBottom": "8px", "border": "1px solid #e0e0e0", "borderLeft": f"3px solid {color}", "bgcolor": "#fafafa", "borderRadius": "4px"},
            "subwidgets": [
                {"widgettype": "HBox", "options": {"alignItems": "center", "marginBottom": "6px"}, "subwidgets": [
                    {"widgettype": "Text", "options": {"text": f"#{i+1}", "cfontsize": 12, "fontWeight": "bold", "color": color, "marginRight": "8px"}},
                    *badges,
                    {"widgettype": "Text", "options": {"text": f"  {h['id'][:16]}", "cfontsize": 11, "color": "#999", "marginLeft": "8px"}}
                ]},
                {"widgettype": "Text", "options": {"text": h["text"][:300], "cfontsize": 13, "color": "#333", "lineHeight": "1.6"}}
            ]
        })

return json.dumps({"widgettype": "VBox", "options": {"padding": "20px", "spacing": "4px"}, "subwidgets": subwidgets}, ensure_ascii=False, default=str)
