=== FINAL REVIEW PACKAGE: volcano KB WebUI console ===
Plan: docs/superpowers/plans/2026-07-23-volcano-kb-webui-console.md
Spec: docs/superpowers/specs/2026-07-23-volcano-kb-webui-console-design.md
Note: work is uncommitted on branch 自定义切片平台开发

=== git status (relevant) ===
 M app/controllers/v1/volcano_kb.py
 M app/services/volcano_kb.py
 M test/controllers/test_volcano_kb_api.py
 M test/services/test_volcano_kb.py
 M test/webui/test_volcano_kb_view.py
 M webui/Main.py
 M webui/i18n/de.json
 M webui/i18n/en.json
 M webui/i18n/es.json
 M webui/i18n/id.json
 M webui/i18n/pt.json
 M webui/i18n/ru.json
 M webui/i18n/tr.json
 M webui/i18n/vi.json
 M webui/i18n/zh.json

=== diff stat vs HEAD ===
 app/controllers/v1/volcano_kb.py        |  95 +++++-
 app/services/volcano_kb.py              | 336 ++++++++++++++++----
 test/controllers/test_volcano_kb_api.py |  43 +++
 test/services/test_volcano_kb.py        |  54 +++-
 test/webui/test_volcano_kb_view.py      |  78 +++++
 webui/Main.py                           | 527 ++++++++++++++++++++++++++++----
 webui/i18n/en.json                      |  43 +++
 webui/i18n/zh.json                      |  43 +++
 8 files changed, 1099 insertions(+), 120 deletions(-)

=== FOCUSED: search_slices score/filename + search endpoint ===
def search_slices(query: str, limit: int = 10) -> list:
    """Call search_knowledge API (get_attachment_link=true). Return normalized hits."""
    if not is_enabled():
        return []
    body = {
        **_collection_ref(),
        "query": query,
        "limit": limit,
        "post_processing": {
            "get_attachment_link": True,
            "rerank_switch": False,
            "chunk_group": True,
            "chunk_diffusion_count": 0,
        },
        "dense_weight": 0.5,
    }
    resp = _request("POST", "/api/knowledge/collection/search_knowledge", body=body)
    points = (resp.get("data") or {}).get("result_list") or (resp.get("data") or {}).get("points") or []
    out = []
    for p in points:
        # search result may wrap the point under different keys
        point = p.get("point") if isinstance(p.get("point"), dict) else p
        norm = _normalize_point(point)
        if not norm.get("point_id"):
            continue
        if not norm.get("doc_id"):
            norm["doc_id"] = (
                point.get("doc_id")
                or (point.get("doc") or {}).get("doc_id")
                or (point.get("doc_info") or {}).get("doc_id")
            )
        # score may live on wrapper or point
        score = p.get("score", p.get("rerank_score"))
        if score is None:
            score = point.get("score", point.get("rerank_score"))
        try:
            norm["score"] = float(score) if score is not None else None
        except (TypeError, ValueError):
            norm["score"] = None

        filename = ""
        if norm.get("doc_id"):
            sc = _read_sidecar(norm["doc_id"]) or {}
            filename = sc.get("filename") or sc.get("title") or ""
        if not filename:
            filename = (
                (point.get("doc_info") or {}).get("doc_name")
                or point.get("doc_name")
                or ""
            )
        norm["filename"] = filename
        out.append(norm)
    return out

@router.post("/volcano_kb/search", summary="Search Volcano KB slices by keyword")
def search_knowledge(request: Request, payload: dict = Body(...)):
    if not volcano_kb.is_enabled():
        raise HttpException(
            "",
            status_code=400,
            message="volcano_kb is not enabled; configure volcengine_kb_*",
        )
    query = ((payload or {}).get("query") or "").strip()
    if not query:
        raise HttpException("", status_code=400, message="query is required")
    try:
        limit = int((payload or {}).get("limit") or 10)
    except (TypeError, ValueError):
        limit = 10
    limit = max(1, min(limit, 50))
    try:
        hits = volcano_kb.search_slices(query, limit=limit)
    except volcano_kb.VolcanoKBError as exc:
        raise HttpException("", status_code=502, message=str(exc))
    return utils.get_response(200, {"query": query, "hits": hits, "count": len(hits)})

=== FOCUSED: KB view helpers+tabs (Main.py from _kb_format_ms to _render_application) ===
def _kb_format_ms(ms: int) -> str:
    """将毫秒时长格式化为 mm:ss，供切片详情等展示使用。"""
    s = max(0, int(ms or 0)) // 1000
    return f"{s // 60:02d}:{s % 60:02d}"


def _kb_status_label(status: str) -> str:
    """映射知识库文档处理状态为本地化文案。"""
    mapping = {
        "ready": tr("KB Doc Status Ready"),
        "processing": tr("KB Doc Status Processing"),
        "uploading": tr("KB Doc Status Processing"),
        "failed": tr("KB Doc Status Failed"),
        "local_only": tr("KB Doc Status Local"),
    }
    return mapping.get(status or "", status or "?")


def _render_kb_documents_tab(api_base: str, files: list):
    """原始文档 Tab：上传、文件夹、按文件名过滤、列表与重试/删除/查看切片。"""
    import requests as _req

    name_filter = st.text_input(
        tr("KB Search Filename"),
        key="kb_doc_name_filter",
        placeholder=tr("KB Search Filename"),
    )

    with st.expander(tr("KB Upload Document"), expanded=False):
        with st.form("kb_upload_form"):
            uploaded = st.file_uploader(
                tr("Upload Videos"),
                type=["mp4", "mov", "avi", "flv", "mkv"],
                accept_multiple_files=True,
                key="kb_uploader",
            )
            submitted = st.form_submit_button(tr("Upload and Slice"))
        if submitted and uploaded:
            for f in uploaded:
                try:
                    r = _req.post(
                        f"{api_base}/videos",
                        files={"file": (f.name, f.getvalue(), "video/mp4")},
                        timeout=120,
                    )
                    if r.status_code == 200:
                        st.success(
                            f"{f.name} -> {r.json().get('data', {}).get('doc_id')}"
                        )
                    else:
                        st.error(f"{f.name}: {r.text}")
                except Exception as exc:
                    st.error(f"{f.name}: {exc}")
            st.rerun()

    with st.expander(tr("Upload Folder"), expanded=False):
        folder_path = st.text_input(
            tr("Local Folder Path"),
            key="kb_folder_path",
            placeholder="/path/to/videos",
            help=tr("Upload Folder Help"),
        )
        if st.button(tr("Upload Folder and Slice"), width="stretch"):
            if not folder_path.strip():
                st.error(tr("Please Enter Folder Path"))
            else:
                with st.spinner(tr("Uploading Folder")):
                    try:
                        r = _req.post(
                            f"{api_base}/videos/folder",
                            json={"path": folder_path.strip()},
                            timeout=600,
                        )
                        if r.status_code == 200:
                            data = r.json().get("data") or {}
                            st.success(
                                tr("Upload Folder Done").format(
                                    ok=data.get("ok", 0),
                                    total=data.get("total", 0),
                                    failed=data.get("failed", 0),
                                )
                            )
                            for item in data.get("results") or []:
                                if not item.get("ok"):
                                    st.warning(
                                        f"{item.get('filename')}: {item.get('error')}"
                                    )
                        else:
                            st.error(r.text)
                    except Exception as exc:
                        st.error(str(exc))
                st.rerun()

    filtered = files
    needle = (name_filter or "").strip().lower()
    if needle:
        filtered = [
            item
            for item in files
            if needle in str(item.get("filename") or "").lower()
        ]

    st.subheader(tr("Knowledge Base Videos"))
    if not filtered:
        st.info(tr("No Knowledge Base Videos"))
        return

    for item in filtered:
        doc_id = item.get("doc_id", "")
        slice_count = len(item.get("slices") or []) or item.get("point_num") or 0
        remote_tag = "remote" if item.get("remote") else "local"
        status_text = _kb_status_label(item.get("status", ""))
        label = (
            f"{item.get('filename', '?')} — {status_text} "
            f"({tr('KB Slice Count')}: {slice_count}, {remote_tag})"
        )
        with st.expander(label):
            st.caption(f"doc_id={doc_id}")
            if item.get("title"):
                st.caption(item["title"])
            if item.get("error"):
                st.error(item["error"])
            a1, a2, a3 = st.columns(3)
            if a1.button(tr("KB View Slices"), key=f"kb_view_{doc_id}"):
                st.session_state["kb_slice_filter_doc"] = doc_id
                st.session_state["kb_force_slices"] = True
                st.rerun()
            if a2.button(tr("Retry"), key=f"kb_retry_{doc_id}"):
                try:
                    _req.post(f"{api_base}/videos/{doc_id}/retry", timeout=30)
                except Exception as exc:
                    st.error(str(exc))
                st.rerun()
            if a3.button(tr("Delete"), key=f"kb_del_{doc_id}"):
                try:
                    _req.delete(f"{api_base}/videos/{doc_id}", timeout=30)
                except Exception as exc:
                    st.error(str(exc))
                st.rerun()


def _render_kb_slices_tab(files: list):
    """切片详情：本地 sidecar 扁平化列表，支持文档筛选、切片 ID 搜索与分页。"""
    page_size = 10
    doc_label = {"": tr("KB All Documents")}
    all_slices = []
    for item in files:
        doc_id = item.get("doc_id") or ""
        filename = item.get("filename") or item.get("title") or doc_id or "?"
        if doc_id:
            doc_label[doc_id] = filename
        for sl in item.get("slices") or []:
            row = dict(sl)
            row["doc_id"] = row.get("doc_id") or doc_id
            row["filename"] = filename
            all_slices.append(row)

    doc_ids = [""] + [d for d in doc_label.keys() if d]
    desired = st.session_state.get("kb_slice_filter_doc") or ""
    if desired not in doc_label:
        desired = ""
    last_applied = st.session_state.get("kb_slice_filter_applied")
    if desired != last_applied:
        st.session_state["kb_slice_doc_select"] = desired
        st.session_state["kb_slice_filter_applied"] = desired
        st.session_state["kb_slice_page"] = 1
    elif "kb_slice_doc_select" not in st.session_state:
        st.session_state["kb_slice_doc_select"] = desired

    t1, t2, t3 = st.columns([1, 2, 2])
    with t1:
        total_placeholder = st.empty()
    with t2:
        selected_doc = st.selectbox(
            tr("KB All Documents"),
            options=doc_ids,
            format_func=lambda d: doc_label.get(d, d),
            key="kb_slice_doc_select",
            label_visibility="collapsed",
        )
    with t3:
        slice_id_q = st.text_input(
            tr("KB Search Slice Id"),
            key="kb_slice_id_filter",
            placeholder=tr("KB Search Slice Id"),
            label_visibility="collapsed",
        )

    if selected_doc != st.session_state.get("kb_slice_filter_doc"):
        st.session_state["kb_slice_filter_doc"] = selected_doc
        st.session_state["kb_slice_filter_applied"] = selected_doc
        st.session_state["kb_slice_page"] = 1

    filtered = all_slices
    if selected_doc:
        filtered = [s for s in filtered if s.get("doc_id") == selected_doc]
    needle = (slice_id_q or "").strip().lower()
    if needle:
        filtered = [
            s
            for s in filtered
            if needle in str(s.get("point_id") or "").lower()
        ]

    total = len(filtered)
    total_placeholder.caption(tr("KB Total Slices").format(n=total))

    total_pages = max(1, (total + page_size - 1) // page_size)
    page = int(st.session_state.get("kb_slice_page") or 1)
    if page > total_pages:
        page = total_pages
        st.session_state["kb_slice_page"] = page
    if page < 1:
        page = 1
        st.session_state["kb_slice_page"] = page

    start = (page - 1) * page_size
    page_slices = filtered[start : start + page_size]

    nav1, nav2, nav3 = st.columns([1, 2, 1])
    with nav1:
        if st.button(
            tr("KB Prev Page"),
            key="kb_slice_prev",
            disabled=page <= 1,
            width="stretch",
        ):
            st.session_state["kb_slice_page"] = page - 1
            st.rerun()
    with nav2:
        st.caption(tr("KB Page").format(page=page))
    with nav3:
        if st.button(
            tr("KB Next Page"),
            key="kb_slice_next",
            disabled=page >= total_pages,
            width="stretch",
        ):
            st.session_state["kb_slice_page"] = page + 1
            st.rerun()

    if not page_slices:
        st.info(tr("No Knowledge Base Videos"))
        return

    for idx, sl in enumerate(page_slices):
        img_col, meta_col = st.columns([1, 2])
        with img_col:
            keyframe = sl.get("keyframe_url") or ""
            if keyframe:
                try:
                    st.image(keyframe, use_container_width=True)
                except Exception:
                    pass
        with meta_col:
            start_ms = sl.get("start_ms")
            end_ms = sl.get("end_ms")
            st.markdown(
                f"**{_kb_format_ms(start_ms)} — {_kb_format_ms(end_ms)}**"
            )
            summary = (sl.get("summary") or "").strip()
            st.write(summary if summary else tr("KB No Speech"))
            st.caption(sl.get("filename") or "")
            point_id = sl.get("point_id") or ""
            if point_id:
                st.caption(tr("KB Slice Id"))
                st.code(point_id, language=None)
        if idx < len(page_slices) - 1:
            st.divider()


def _render_kb_search_tab(api_base: str, files: list):
    """知识检索：调用远程 POST /search，展示带分数的召回结果。"""
    import time as _time

    _ = files  # 仅远程检索，不走本地全文

    q = st.text_input(
        tr("KB Search Placeholder"),
        key="kb_search_q",
        placeholder=tr("KB Search Placeholder"),
    )
    c1, c2 = st.columns([1, 3])
    with c1:
        limit = st.number_input(
            "limit",
            min_value=1,
            max_value=50,
            value=10,
            key="kb_search_limit",
        )
    with c2:
        run = st.button(tr("KB Run Search"), type="primary", width="stretch")

    if run and (q or "").strip():
        t0 = _time.time()
        try:
            r = requests.post(
                f"{api_base}/search",
                json={"query": q.strip(), "limit": int(limit)},
                timeout=60,
            )
            elapsed = _time.time() - t0
            if r.status_code == 200:
                data = r.json().get("data") or {}
                hits = data.get("hits") or []
                st.session_state["kb_search_hits"] = hits
                st.session_state["kb_search_count"] = data.get("count", len(hits))
                st.session_state["kb_search_elapsed"] = elapsed
                st.session_state["kb_search_query"] = q.strip()
            else:
                st.error(r.text)
                st.session_state["kb_search_hits"] = []
                st.session_state["kb_search_count"] = 0
                st.session_state["kb_search_elapsed"] = elapsed
        except Exception as exc:
            st.error(str(exc))
            st.session_state["kb_search_hits"] = []
            st.session_state["kb_search_count"] = 0
            st.session_state["kb_search_elapsed"] = _time.time() - t0

    hits = st.session_state.get("kb_search_hits")
    if hits is None:
        return

    st.subheader(tr("KB Search Results"))
    st.caption(
        tr("KB Search Meta").format(
            count=st.session_state.get("kb_search_count", len(hits)),
            seconds=f"{st.session_state.get('kb_search_elapsed', 0):.2f}",
        )
    )
    if not hits:
        st.info(tr("No Knowledge Base Videos"))
        return

    for n, hit in enumerate(hits, start=1):
        img_col, meta_col = st.columns([1, 2])
        with img_col:
            keyframe = hit.get("keyframe_url") or ""
            if keyframe:
                try:
                    st.image(keyframe, use_container_width=True)
                except Exception:
                    pass
        with meta_col:
            st.markdown(
                f"**{_kb_format_ms(hit.get('start_ms'))} — "
                f"{_kb_format_ms(hit.get('end_ms'))}**"
            )
            summary = (hit.get("summary") or "").strip()
            st.write(summary if summary else tr("KB No Speech"))
            if hit.get("filename"):
                st.caption(hit["filename"])
            score = hit.get("score")
            if score is not None:
                try:
                    score_txt = f"{float(score):.4f}"
                except (TypeError, ValueError):
                    score_txt = str(score)
                st.caption(tr("KB Recall Score").format(score=score_txt, n=n))
            point_id = hit.get("point_id") or ""
            if point_id:
                st.caption(tr("KB Slice Id"))
                st.code(point_id, language=None)
        if n < len(hits):
            st.divider()


def _render_volcano_kb_view():
    """火山方舟知识库控制台：Header + Tabs（原始文档 / 切片 / 检索 / 问答）。"""
    import requests as _req
    import time as _time

    if not volcano_kb_service.is_enabled():
        st.warning(tr("Volcano KB Not Configured"))
        return

    api_base = f"http://127.0.0.1:{config.listen_port}/api/v1/volcano_kb"
    collection = config.app.get("volcengine_kb_collection", "") or ""
    project = config.app.get("volcengine_kb_project", "") or ""

    h1, h2 = st.columns([3, 1])
    with h1:
        st.subheader(collection or tr("Volcano Knowledge Base"))
        st.caption(
            f"{tr('KB Official Version')} · {tr('KB Build Success')} · {tr('KB Data Type')}"
        )
        st.caption(f"project={project} · collection={collection}")
        if st.session_state.get("kb_last_sync_at"):
            st.caption(
                tr("KB Last Sync").format(time=st.session_state["kb_last_sync_at"])
            )
    with h2:
        if st.button(tr("Sync Knowledge Base"), type="primary", width="stretch"):
            with st.spinner(tr("Syncing Knowledge Base")):
                try:
                    r = _req.post(f"{api_base}/videos/sync", timeout=120)
                    if r.status_code == 200:
                        data = r.json().get("data") or {}
                        st.success(
                            tr("Sync Knowledge Base Done").format(
                                docs=data.get("doc_count", 0),
                                slices=data.get("slice_count", 0),
                            )
                        )
                        st.session_state["kb_last_sync_at"] = _time.strftime(
                            "%Y-%m-%d %H:%M:%S"
                        )
                    else:
                        st.error(r.text)
                except Exception as exc:
                    st.error(str(exc))
            st.rerun()
        if st.button(tr("Refresh List"), width="stretch"):
            st.rerun()

    # 使用稳定 tab id，避免语言切换后 radio 状态与文案错位。
    tab_ids = ["documents", "slices", "search", "qa"]
    tab_labels = {
        "documents": tr("KB Tab Documents"),
        "slices": tr("KB Tab Slices"),
        "search": tr("KB Tab Search"),
        "qa": tr("KB Tab QA"),
    }
    if st.session_state.get("kb_console_tab") not in tab_ids:
        st.session_state["kb_console_tab"] = "documents"
    if st.session_state.pop("kb_force_slices", False):
        st.session_state["kb_console_tab"] = "slices"

    choice = st.radio(
        "kb_console_tabs",
        tab_ids,
        horizontal=True,
        label_visibility="collapsed",
        key="kb_console_tab",
        format_func=lambda tab_id: tab_labels.get(tab_id, tab_id),
    )

    try:
        r = _req.get(f"{api_base}/videos", timeout=30)
        files = r.json().get("data", {}).get("files", []) if r.status_code == 200 else []
    except Exception as exc:
        st.error(str(exc))
        files = []

    if choice == "documents":
        _render_kb_documents_tab(api_base, files)
    elif choice == "slices":
        _render_kb_slices_tab(files)
    elif choice == "search":
        _render_kb_search_tab(api_base, files)
    else:
        st.info(tr("KB QA Coming Soon"))




=== Progress ledger ===
# SDD Progress Ledger

Plan: docs/superpowers/plans/2026-07-23-volcano-kb-webui-console.md
Branch: 自定义切片平台开发
Start HEAD: ceaec5d

## Tasks
- Task 1: pending
- Task 2: pending
- Task 3: pending
- Task 4: pending
- Task 5: pending

## Minor findings roll-up

Task 1: complete (uncommitted, review clean; minor: score_optional assert / doc_name fallback test)

Task 2: complete (uncommitted, review clean; minor: no limit-clamp test)

Task 3: complete (uncommitted, i18n keys added; review pending)

Task 3: complete (uncommitted, review clean)

Task 4: complete (uncommitted, review clean after test fix)
Minor roll-up: sync toast flash; silent GET non-200; action keys doc_id-only; remote/local untranslated

Task 5: complete (uncommitted, review clean)
Minor roll-up: empty-state copy; search error chrome; source-scan tests; toast flash (T4); silent GET (T4)
