scripts/xkb_jev.py
Rama main · réplica de lectura del repositorio original.
#!/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}")