scripts/xkb_jev_shadow_report.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""餘弦跟 jev 在哪裡不一致——影子模式的讀取端。
`_shadow_compare` 每次召回都把兩邊的判斷存進 relevance_shadow:餘弦的分數、
餘弦當下的決定(kept),以及 jev 對同一批候選的判斷。這支把那些資料讀出來。
**沒有讀取端的觀測資料等於不存在。** cold_knowledge() 在這個專案裡存在了兩週
沒有任何呼叫端,結果是「看得到、不會動」。所以這支跟著影子模式一起上。
這支不建議門檻,它只把不一致攤開來。要選門檻得判斷「誰對」,而那要看個案——
報告把個案排好,讓人或模型看得下去。自動挑一個讓兩邊最像的數字是反過來的:
影子模式的目的不是複製餘弦,是找出餘弦錯在哪。
用法:
python3 scripts/xkb_jev_shadow_report.py
python3 scripts/xkb_jev_shadow_report.py --json
python3 scripts/xkb_jev_shadow_report.py --limit 40
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import xkb_console
import xkb_paths
xkb_console.use_utf8()
# 「jev 覺得這筆有回答問題」的暫定分界。**這不是門檻**,只是把報告分成兩半好
# 閱讀的一條線。2026-09-24 的抽樣裡正面回答落在 0.30~0.54、不相關落在 0.01~0.02,
# 所以中間取 0.15 能把兩群分開。真正的門檻要等資料夠多再定。
READ_SPLIT = 0.15
def load_rows(limit: int = 0) -> list[dict]:
db_path = xkb_paths.SERVICE_DB
if not db_path.exists():
return []
db = None
try:
db = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
db.row_factory = sqlite3.Row
sql = ("SELECT at, query, record_id, cosine, jev, kept, floor"
" FROM relevance_shadow WHERE jev IS NOT NULL ORDER BY id DESC")
if limit:
sql += f" LIMIT {int(limit)}"
rows = db.execute(sql).fetchall()
except sqlite3.Error as err:
print(f"讀不到 relevance_shadow:{err}", file=sys.stderr)
return []
finally:
if db is not None:
try:
db.close()
except Exception: # noqa: BLE001
pass
return [dict(r) for r in rows]
def agreement(rows: list[dict], split: float = READ_SPLIT) -> dict[str, list[dict]]:
"""把每一筆分到四格:兩邊都說要 / 都說不要 / 各說各話。
「各說各話」的兩格才是這份報告的重點。兩邊都同意的那些,換不換模型都一樣。
"""
buckets: dict[str, list[dict]] = {
"both_yes": [], "both_no": [],
"cosine_only": [], # 餘弦留下、jev 說沒回答問題
"jev_only": [], # 餘弦丟掉、jev 說有回答問題
}
for row in rows:
cosine_yes = bool(row.get("kept"))
jev_yes = float(row.get("jev") or 0.0) >= split
if cosine_yes and jev_yes:
buckets["both_yes"].append(row)
elif not cosine_yes and not jev_yes:
buckets["both_no"].append(row)
elif cosine_yes:
buckets["cosine_only"].append(row)
else:
buckets["jev_only"].append(row)
return buckets
def spread(rows: list[dict], field: str) -> tuple[float, float, float]:
"""(最小, 中位, 最大)。空的回 (0,0,0)。"""
values = sorted(float(r.get(field) or 0.0) for r in rows)
if not values:
return 0.0, 0.0, 0.0
return values[0], values[len(values) // 2], values[-1]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json", action="store_true")
parser.add_argument("--limit", type=int, default=0,
help="只看最近 N 筆觀測(預設全部)")
parser.add_argument("--show", type=int, default=12, help="每一格列幾個例子")
parser.add_argument("--split", type=float, default=READ_SPLIT)
args = parser.parse_args()
rows = load_rows(args.limit)
if not rows:
print("relevance_shadow 還沒有資料——影子模式剛上線,或 jev 沒跑成。")
print("(jev 沒跑成時刻意不寫入:一整批 NULL 會長得像「它覺得都不相關」。)")
return 0
buckets = agreement(rows, args.split)
total = len(rows)
print(f"影子觀測 {total} 筆(只算 jev 真的跑成的)")
print(f" 判讀分界:jev ≥ {args.split} · 餘弦地板:{rows[0].get('floor')}")
print()
print(f" 兩邊都說要注入 {len(buckets['both_yes']):>5}")
print(f" 兩邊都說不要 {len(buckets['both_no']):>5}")
print(f" 只有餘弦說要 {len(buckets['cosine_only']):>5} ← 可能是被硬塞進去的")
print(f" 只有 jev 說要 {len(buckets['jev_only']):>5} ← 可能是被門檻誤殺的")
print()
lo, mid, hi = spread(buckets["both_yes"], "jev")
print(f" 餘弦留下的那些,jev 給的分數:{lo:.2f} / 中位 {mid:.2f} / {hi:.2f}")
lo, mid, hi = spread(buckets["jev_only"], "cosine")
print(f" jev 說要但被丟掉的,餘弦分數:{lo:.2f} / 中位 {mid:.2f} / {hi:.2f}")
print()
if args.json:
print(json.dumps({k: v[: args.show] for k, v in buckets.items()},
ensure_ascii=False, indent=2))
return 0
for name, title in (("jev_only", "被餘弦門檻丟掉、但 jev 認為有回答問題的"),
("cosine_only", "餘弦留下、但 jev 認為沒回答問題的")):
picked = buckets[name]
if not picked:
continue
picked.sort(key=lambda r: -abs(float(r.get("jev") or 0)
- float(r.get("cosine") or 0)))
print(f" {title}(差距最大的 {min(args.show, len(picked))} 筆):")
print(f" {'餘弦':>5} {'jev':>5} 問題 / 知識")
for row in picked[: args.show]:
print(f" {float(row.get('cosine') or 0):>5.2f} "
f"{float(row.get('jev') or 0):>5.2f}"
f" {str(row.get('query') or '')[:26]} → "
f"{str(row.get('record_id') or '')[:34]}")
print()
print(" 這支只報告。餘弦仍然是唯一做決定的那一個。")
return 0
import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py
if __name__ == "__main__":
xkb_usage.record(__file__)
raise SystemExit(main())