Ctrl K
ComunidadGuías…

scripts/xkb_jev.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""jev:只做判斷的模型。XKB 用它回答「這筆知識有沒有回答這個問題」。

jev 不是聊天模型,所以 `_llm.call` 打不到它——它沒有 `/v1/chat/completions`:

    POST {LLM_API_URL}/systemone
    {"model": "jev-1.13", "state": "<要判斷的內容>",
     "questions": {"<名稱>": {"type": "noul|choice|score", "instructions": ...}}}

回傳的是有型別的答案,不是文字。`noul` 回 0~1 的機率,`choice` 回選項,
`score` 回等級。計費只算輸入。

**為什麼值得接。** 2026-09-24 在正式資料上量過:一次呼叫裡塞四個判斷,總共
1.85 秒——跟單一判斷一樣,因為 questions 是平行評估的。所以 N 個候選只要一次
往返,不是 N 次。而它的分離度遠勝餘弦:

    同一批候選     jev          餘弦
    正面回答       0.31 / 0.54  0.480
    完全不相關     0.02 / 0.01  0.446 / 0.435

餘弦把相關與不相關壓在 0.435~0.480 這 7% 的區間裡,而固定門檻 0.55 就壓在
那一段的上方——正式資料上 980 次召回丟掉了 3,886 筆候選,而抽樣檢查發現
「食品展的客戶通常怎麼找」這種問題的正確答案就落在 0.480,被丟掉了。餘弦分不
出「講同一個主題」跟「回答了這個問題」,jev 的 noul 正好就是後者。

**這支不做決定。** 它只回答。現行 Knowledge Service 預設在合併點採用判斷;
`XKB_JEV_DECIDE=0` 才停用,影子比較另由 `XKB_JEV_SHADOW` 控制。
既有相關性政策由呼叫端管理,本 adapter 不另設門檻。

**失敗一律開放通過。** 判斷不出來時回 None,而不是 0。這兩件事差很多:0 是
「jev 說不相關」,None 是「jev 沒跑」。把它們寫成同一件事,就會變成 jev 一掛掉
知識庫就靜默關閉——那正是這個專案吃過最大虧的那種失敗。
"""
from __future__ import annotations

import json
import sys
import time
from concurrent.futures import ThreadPoolExecutor
import urllib.error
import urllib.request
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))

import xkb_failures
from runtime_config import runtime_env, llm_service_env

_SKILL_DIR = Path(__file__).resolve().parent.parent
_CONFIG_FILE = _SKILL_DIR / "config" / "llm.json"

# Judge has its own XKB_JUDGE_API_URL/API_KEY route, independent of generation.
# Legacy LLM_* is supported only when no scoped judge endpoint/key is set.
DEFAULT_JUDGE_MODEL = "jev-1.13"
JUDGE_PATH = "/systemone"

# 判斷要嘛很快要嘛沒用。實測 1.85 秒,留三倍餘裕;超過就當它沒跑。
# 召回路徑上多等十秒比少一筆弱候選糟得多。
TIMEOUT_SECONDS = 6


def judge_model() -> str:
    env_model = runtime_env().get("XKB_JUDGE_MODEL", "")
    if env_model:
        return env_model
    try:
        cfg = json.loads(_CONFIG_FILE.read_text(encoding="utf-8"))
        return cfg.get("judge_model") or DEFAULT_JUDGE_MODEL
    except Exception as err:  # noqa: BLE001
        if _CONFIG_FILE.exists():
            xkb_failures.note("jev config", err, detail=str(_CONFIG_FILE))
        return DEFAULT_JUDGE_MODEL


def available() -> bool:
    """有沒有可用的憑證。沒有的話呼叫端要當作「沒跑」,不是「判斷為否」。"""
    settings = llm_service_env("judge")
    return bool(settings.get("LLM_API_URL") and settings.get("LLM_API_KEY"))


MAX_BODY_BYTES = 32 * 1024


def _body(state: str, questions: dict) -> bytes:
    return json.dumps({"model": judge_model(), "state": state,
                       "questions": questions}, ensure_ascii=False,
                      separators=(",", ":")).encode("utf-8")


def judge(state: str, questions: dict[str, dict],
          *, timeout: float = TIMEOUT_SECONDS) -> dict | None:
    """問 jev 一組問題。回 answers;判斷不出來回 None。

    None 與「答案是 0」是兩件不同的事,呼叫端必須分開處理——見模組說明。
    """
    if not state or not questions or not available():
        return None
    settings = llm_service_env("judge")
    url = settings["LLM_API_URL"].rstrip("/") + JUDGE_PATH
    body = _body(state, questions)
    if len(body) > MAX_BODY_BYTES:
        xkb_failures.note("jev budget", ValueError("request exceeds 32 KiB"))
        return None
    request = urllib.request.Request(
        url, data=body, method="POST",
        headers={"Content-Type": "application/json",
                 "Authorization": f"Bearer {settings['LLM_API_KEY']}"})
    try:
        with urllib.request.urlopen(request, timeout=timeout) as response:
            payload = json.loads(response.read().decode("utf-8"))
    except (urllib.error.URLError, OSError, json.JSONDecodeError,
            ValueError) as err:
        xkb_failures.note("jev judge", err, detail=url)
        return None
    if not isinstance(payload, dict):
        return None
    answers = payload.get("answers")
    if not isinstance(answers, dict):
        data = payload.get("data")
        answers = data.get("answers") if isinstance(data, dict) else None
    return answers if isinstance(answers, dict) else None


def relevance(query: str, candidates: list[tuple[str, str]],
              *, timeout: float = TIMEOUT_SECONDS) -> dict[str, float] | None:
    """每個候選有沒有回答這個問題。回 {key: 0~1};判斷不出來回 None。

    以最終序列化 UTF-8 bytes 分批,每批含 model/state 不超過 32 KiB。
    小批維持一次呼叫;大批最多四路並行,共用 timeout deadline。
    缺失或失敗批次不產生否定答案,呼叫端保留並警告未判斷項目。

    候選文字會被截斷:instructions 是判斷準則,不是整篇文件,而卡片可以很長。
    標題加摘要足以判斷「能否推進當前需求」——真要整篇才判斷得出來的情況,
    那筆本來就不該直接注入。
    """
    if not query or not candidates:
        return None
    questions = {}
    slots: dict[str, list[str]] = {}
    text_slots: dict[str, str] = {}
    for index, (key, text) in enumerate(candidates):
        if not key or not text:
            continue
        # All candidates share the same current-turn state. Identical effective
        # questions need one verdict, even when multiple retrieval legs found
        # them. Keep every key for fan-out; do not collapse retrieval support.
        effective_text = _trim(text)
        if effective_text in text_slots:
            slots[text_slots[effective_text]].append(key)
            continue
        # 問題名稱用序號,不用候選的 key:key 是檔案路徑,裡面有斜線與中文,
        # 而它會變成回應 JSON 的欄位名。序號對回去就好。
        slot = f"q{index}"
        text_slots[effective_text] = slot
        slots[slot] = [key]
        questions[slot] = {
            "type": "noul",
            "instructions": ("這段知識能否提供推進當前需求的做法、限制或經驗;"
                             "只匹配過去話題而無助於當前需求不算相關。"
                             f"知識內容:{effective_text}"),
        }
    if not questions:
        return None
    state = ("判斷證據能否推進使用者當前需求,陳述計畫、困難或限制也可能需要知識。"
             "前文只協助理解指代;當前發言改變方向時,以當前需求為準。\n"
             "使用者已暫停或放棄的話題不算當前需求。已在助手前文完整說過的做法,"
             "除非使用者要求重述或出現新的適用條件,否則沒有新增幫助;"
             "未回應不代表接受或拒絕。\n"
             f"對話情境:{_trim(query, 4096)}")
    batches, batch = [], {}
    for slot, question in questions.items():
        proposed = {**batch, slot: question}
        if len(_body(state, proposed)) > MAX_BODY_BYTES:
            if batch:
                batches.append(batch)
            batch = {slot: question}
        else:
            batch = proposed
    if batch:
        batches.append(batch)
    # A shared deadline bounds queued work; small requests stay one call.
    deadline = time.monotonic() + timeout
    def run(batch):
        remaining = deadline - time.monotonic()
        return judge(state, batch, timeout=remaining) if remaining > 0 else None
    if len(batches) == 1:
        results = [run(batches[0])]
    else:
        with ThreadPoolExecutor(max_workers=min(4, len(batches))) as pool:
            results = list(pool.map(run, batches))
    answers = {}
    for result in results:
        if result is not None:
            answers.update(result)
    if not answers:
        return None
    out: dict[str, float] = {}
    for slot, keys in slots.items():
        answer = answers.get(slot)
        if not isinstance(answer, dict):
            continue
        value = answer.get("noul")
        if isinstance(value, (int, float)) and not isinstance(value, bool) and 0 <= value <= 1:
            for key in keys:
                out[key] = float(value)
    # 一個都對不上就是回應的形狀跟預期不同——那是「沒跑成」,不是「全部不相關」。
    return out or None


def _trim(text: str, limit: int = 900) -> str:
    text = " ".join(str(text or "").split())
    return text if len(text) <= limit else text[:limit] + "…"


import xkb_usage  # noqa: E402  — 量測誰在跑,見 scripts/xkb_usage.py

if __name__ == "__main__":
    xkb_usage.record(__file__)
    demo_query = "食品展的客戶通常怎麼找"
    demo = [
        ("cards/a.md", "公開企業名錄通常不直接提供完整聯絡資訊;食品展官方"
                       "資料可能只有公司名稱、攤位、官網、產品與簡介,沒有電話。"),
        ("cards/b.md", "AI 影片生成的鏡頭運動提示詞寫法,包含推軌、環繞與特寫。"),
    ]
    scores = relevance(demo_query, demo)
    if scores is None:
        print("jev 沒跑成——憑證沒設,或端點打不通。這不代表候選不相關。")
    else:
        print(f"問題:{demo_query}")
        for key, value in sorted(scores.items(), key=lambda kv: -kv[1]):
            print(f"  {value:.2f}  {key}")