# -*- coding: utf-8 -*- """ Jev-style 判別式決策互動 POC(繁體中文) 後端:jevos(feder-cr/jev,OpenVINO int8,CPU 可跑) - 是非題 (noul):輸入狀態+問題,回傳 P(yes) - 多選題 (choice):輸入狀態+問題+候選選項,回傳每選項機率 - 每次回應標示延遲與 output_tokens=0(零解碼,判別 vs 生成的核心差異) 免費部署:Hugging Face Spaces(CPU Basic 免費方案即可) 1. 在 huggingface.co/new-space 建一個 Gradio Space(公開) 2. 上傳 app.py+requirements.txt 3. 首次啟動會自動下載 jevos(約 660MB)並快取於 /data,之後秒啟 本地運行:見 README.md """ import json import os import shutil import subprocess import threading import time import zipfile import tarfile import gradio as gr import requests import spaces # ZeroGPU 執行期內建;不可寫進 requirements.txt(會蓋掉內建模組) BASE = os.path.dirname(os.path.abspath(__file__)) if os.path.isdir("/data") and os.access("/data", os.W_OK): DATA = "/data" _DATA_PERSISTENT = True else: DATA = os.path.join(BASE, ".poc-data") _DATA_PERSISTENT = False os.makedirs(DATA, exist_ok=True) BIN_URL = "https://github.com/feder-cr/jev/releases/download/jevos-v3/jev-linux-x64.tar.gz" MODEL_URL = "https://github.com/feder-cr/jev/releases/download/jevos-v3/jevos-v3-openvino-int8.zip" API = "http://127.0.0.1:8017/v1/systemone" _server = None _lock = threading.Lock() # 後端狀態(給 UI 顯示用,避免首次下載時使用者以為當機) _progress = {"phase": "idle", "pct": 0, "msg": ""} _last_pct_shown = -1 def _set_phase(phase, msg="", pct=None): global _last_pct_shown _progress["phase"] = phase _progress["msg"] = msg if pct is not None: _progress["pct"] = pct print(f"[poc] {msg or phase}", flush=True) def status_text(): ph = _progress["phase"] if ph == "ready": return "✅ 後端就緒(jevos 運行中)" if ph == "downloading": return f"⏳ 下載模型中 {_progress['pct']}%(首次啟動需下載約 660MB,請稍候)" if ph == "extracting": return "⏳ 解壓縮模型中…" if ph == "starting": return "⏳ 啟動 jevos 中(載入模型約需 10 秒)…" if ph == "error": return f"❌ 後端錯誤:{_progress['msg']}" return "⏳ 後端準備中…" def _dl(url, dst): """下載檔案(支援斷點續傳+重試),顯示進度百分比。""" global _last_pct_shown if os.path.exists(dst): return dst name = url.split("/")[-1] tmp = dst + ".part" _last_pct_shown = -1 for attempt in range(1, 4): try: start = os.path.getsize(tmp) if os.path.exists(tmp) else 0 if start > 0: print(f"[poc] 續傳 {name}(從 {start // 1024 // 1024}MB 繼續)", flush=True) else: _set_phase("downloading", f"開始下載 {name}") headers = {"Range": f"bytes={start}-"} if start else {} with requests.get(url, headers=headers, stream=True, timeout=30) as r: r.raise_for_status() total = int(r.headers.get("content-length", 0) or 0) if start and r.status_code != 206: # 伺服器不支援續傳,從頭來 print("[poc] 伺服器不支援續傳,從頭下載", flush=True) start, total, mode = 0, total, "wb" else: mode = "ab" if start else "wb" full_total = total + start downloaded = start with open(tmp, mode) as f: for chunk in r.iter_content(chunk_size=1024 * 256): if not chunk: continue f.write(chunk) downloaded += len(chunk) if full_total: pct = min(100, downloaded * 100 // full_total) _progress["pct"] = pct if pct // 10 != _last_pct_shown // 10: _last_pct_shown = pct print(f"[poc] 下載進度 {pct}%", flush=True) os.rename(tmp, dst) _progress["pct"] = 100 print("[poc] 下載完成", flush=True) return dst except (requests.RequestException, OSError) as e: print(f"[poc] 下載中斷(嘗試 {attempt}/3):{type(e).__name__},5 秒後續傳重試", flush=True) time.sleep(5) raise RuntimeError(f"下載失敗,已重試 3 次:{name}") def backend_ready(): """不阻塞地檢查 jevos 是否已在服務。""" try: requests.post(API, json={"model": "jev-latest", "state": "ping", "questions": {"q": {"type": "noul", "instructions": "ok?"}}}, timeout=3) return True except Exception: return False def ensure_backend(): """下載、解壓、啟動 jevos(冪等,可重複呼叫)。""" global _server with _lock: if _server is not None and _server.poll() is None: return _ensure_backend_locked() def _ensure_backend_locked(): global _server # 先看是不是已經有 server 在跑(例如本機手動啟動的)→ 有就直接用,不下載 if backend_ready(): _set_phase("ready", "jevos 已在運行") return try: jev_dir, binary = _find_or_prepare_backend() # 保險:確保可執行(git/網頁上傳可能掉執行權限) try: os.chmod(binary, 0o755) except Exception: pass _ensure_model_weights(os.path.join(jev_dir, "model")) _set_phase("starting", "啟動 jevos…") _server = subprocess.Popen([binary, "serve"], cwd=jev_dir, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) for _ in range(90): time.sleep(1) if backend_ready(): _set_phase("ready", "jevos 就緒") return raise RuntimeError("jevos 啟動逾時(90 秒)") except Exception as e: _set_phase("error", f"{type(e).__name__}: {e}") raise def _ensure_model_weights(model_dir): """確保 openvino_model.bin 存在;缺失時從 release 下載 zip 並只解出該檔(支援續傳)。""" bin_path = os.path.join(model_dir, "openvino_model.bin") if os.path.isfile(bin_path): return print("[poc] 缺少 openvino_model.bin,從 release 下載模型包並解出該檔…", flush=True) _set_phase("downloading", "下載模型包(約 630MB,只取權重檔,支援續傳)…") zf = _dl(MODEL_URL, os.path.join(DATA, "jevos-v3-openvino-int8.zip")) _set_phase("extracting", "解出 openvino_model.bin…") os.makedirs(model_dir, exist_ok=True) with zipfile.ZipFile(zf) as z: for name in z.namelist(): if name.endswith("openvino_model.bin"): with z.open(name) as src, open(bin_path, "wb") as dst: shutil.copyfileobj(src, dst, length=1024 * 1024) print("[poc] openvino_model.bin 就緒", flush=True) return raise RuntimeError("模型包內找不到 openvino_model.bin") def _find_or_prepare_backend(): """找後端檔案,順序:repo 內已解壓 → 快取 → repo 內壓縮檔 → 線上下載。 回傳 (jev_dir, binary)。jev_dir 下保證有執行檔與 model/(巢狀佈局會建連結)。""" # 1. 已解壓好的(手動上傳到 repo 根目錄,或之前快取的) for base in (BASE, DATA): # 1a. 標準佈局:/jev/{jev(執行檔), model/, *.so} d = os.path.join(base, "jev") b = os.path.join(d, "jev") if os.path.isfile(b) and os.path.isdir(os.path.join(d, "model")): print(f"[poc] 使用現成後端:{b}", flush=True) return d, b # 1b. 巢狀佈局:/jev/jev/{jev(執行檔), *.so},model/ 在 /jev/model/ # (tarball 內層目錄被原樣上傳時會長這樣;執行檔在內層,./model 要能被找到) inner = os.path.join(d, "jev") b2 = os.path.join(inner, "jev") outer_model = os.path.join(d, "model") if os.path.isfile(b2) and os.path.isdir(outer_model): link = os.path.join(inner, "model") if not os.path.exists(link): try: os.symlink(outer_model, link) print("[poc] 巢狀佈局:已建立 model 連結", flush=True) except Exception as e: print(f"[poc] 建 model 連結失敗:{e}", flush=True) print(f"[poc] 使用現成後端(巢狀):{b2}", flush=True) return inner, b2 # 2. repo 根目錄有壓縮檔(手動上傳)→ 解到 DATA jev_dir = os.path.join(DATA, "jev") tgz = os.path.join(BASE, "jev-linux-x64.tar.gz") zf = os.path.join(BASE, "jevos-v3-openvino-int8.zip") if os.path.exists(tgz) and os.path.exists(zf): print("[poc] 使用 repo 內附壓縮檔解壓…", flush=True) _set_phase("extracting", "解壓縮 repo 內附檔案…") os.makedirs(DATA, exist_ok=True) with tarfile.open(tgz) as tf: tf.extractall(DATA, filter="data") print("[poc] 主程式解壓完成,解壓縮模型(約 630MB,需要幾分鐘)…", flush=True) _extract_zip_progress(zf, jev_dir) print("[poc] 模型解壓完成", flush=True) return jev_dir, os.path.join(jev_dir, "jev") # 3. 線上下載(Spaces 執行期可連外網) os.makedirs(DATA, exist_ok=True) tgz = _dl(BIN_URL, os.path.join(DATA, "jev-linux-x64.tar.gz")) _set_phase("extracting", "解壓縮 jevos 主程式…") with tarfile.open(tgz) as tf: tf.extractall(DATA, filter="data") zf = _dl(MODEL_URL, os.path.join(DATA, "jevos-v3-openvino-int8.zip")) _set_phase("extracting", "解壓縮模型(約 630MB,需要幾分鐘)…") _extract_zip_progress(zf, jev_dir) print("[poc] 模型解壓完成", flush=True) return jev_dir, os.path.join(jev_dir, "jev") def _extract_zip_progress(zf, jev_dir): """解壓 zip 並印進度(避免長時間無輸出被誤判卡死)。""" with zipfile.ZipFile(zf) as z: members = z.namelist() total = len(members) for i, m in enumerate(members): z.extract(m, jev_dir) if (i + 1) % 25 == 0 or (i + 1) == total: print(f"[poc] 模型解壓縮 {i + 1}/{total}", flush=True) def decide(state, mode, question, options_text): if not (state or "").strip() or not (question or "").strip(): return "請先輸入狀態文字與問題", "", "", status_text() # 後端還沒好就不要卡住:直接告訴使用者目前進度 if not backend_ready(): ensure_backend_if_needed() return status_text() + ",請稍後再按一次「送出判別」。", "", "", status_text() ensure_backend() t0 = time.perf_counter() if mode == "是非題": payload = {"model": "jev-latest", "state": state, "questions": {"q1": {"type": "noul", "instructions": question}}} else: criteria = {} for line in options_text.strip().split("\n"): line = line.strip() if not line: continue if ":" in line: name, desc = line.split(":", 1) elif ":" in line: name, desc = line.split(":", 1) else: name, desc = line, "" name = name.strip() desc = desc.strip() or name # 無描述時用名稱代替(choice 需要 criteria 描述) criteria[name] = desc if not criteria: return "請至少輸入一個候選選項", "", "", status_text() payload = {"model": "jev-latest", "state": state, "questions": {"q1": {"type": "choice", "instructions": question, "criteria": criteria}}} try: r = requests.post(API, json=payload, timeout=120) r.raise_for_status() data = r.json() except Exception as e: return f"後端呼叫失敗:{type(e).__name__}({e})", "", "", status_text() ms = (time.perf_counter() - t0) * 1000 try: ans = data["answers"]["q1"] except (KeyError, TypeError): return f"後端回傳格式異常:{str(data)[:300]}", "", "", status_text() usage = data.get("usage", {}) if ans["type"] == "noul": p = ans["noul"] bars = f"P(yes) = {p:.3f}\n" + "█" * int(p * 40) + f" {p*100:.1f}%" summary = f"答案:{'是' if p >= 0.5 else '否'}(信心 {p if p>=0.5 else 1-p:.1%})" else: probs = ans["probabilities"] bars = "\n".join(f"{k}: {'█' * int(v*40)} {v*100:.1f}%" for k, v in sorted(probs.items(), key=lambda x: -x[1])) summary = (f"答案:{ans['choice']}(信心 {ans.get('confidence', 0):.1%})\n" f"各選項機率:{json.dumps(probs, ensure_ascii=False)}") meta = (f"延遲 {ms:.0f} ms | input_tokens={usage.get('input_tokens','?')} | " f"output_tokens={usage.get('output_tokens','?')}(零解碼)") return summary, bars, meta, status_text() def ensure_backend_if_needed(): """確保背景下載執行緒有在跑(不阻塞)。""" t = threading.Thread(target=ensure_backend, daemon=True) t.start() PRESETS = { "客服訊息分類": ("客人說:「這個月帳單怎麼多了一筆我沒買的東西?」", "多選題", "這封訊息應該派給哪個團隊?", "billing: 帳單、付款、退款\nshipping: 寄送、包裹遺失損壞\ntech: 產品故障不會動"), "是否為退款請求": ("I want my money back for the broken headphones.", "是非題", "這是一則退款請求嗎?", ""), "內容審核": ("這家餐廳難吃到我想給一星,老闆還敢嗆客人。", "是非題", "這則評論含有針對店家的惡意攻擊嗎?", ""), } def load_preset(name): s, m, q, o = PRESETS[name] return s, m, q, o @spaces.GPU(duration=10) def gpu_compat_check(): """ZeroGPU 硬體要求至少一個 @spaces.GPU 函數才能啟動。 jevos 為純 CPU 運算(OpenVINO),此函數僅為通過啟動檢查的佔位,實際不參與判別、不消耗有意義的 GPU 配額。""" return "ZeroGPU 相容檢查通過(判別運算實際於 CPU 執行)" CSS = """ .gradio-container { max-width: 900px !important; } .bar textarea { font-family: monospace !important; white-space: pre !important; } """ with gr.Blocks(title="Jev-style 判別式決策 POC") as demo: gr.Markdown("# ⚡ Jev-style 判別式決策 POC\n" "輸入一段**狀態文字**+**問題**,模型直接輸出**機率分佈**——不生成任何文字(`output_tokens=0`)。" "這就是 System 1 判別引擎:傳統 LLM Agent 每步 1–3 秒還可能寫壞 JSON,這裡一次前向傳播就給出完整機率分佈。\n\n" "> ⏰ **首次啟動**需要下載模型(約 660MB),請看下方「後端狀態」等它就緒再按送出,之後啟動都是秒開。") status_box = gr.Textbox(label="後端狀態", value="⏳ 後端準備中…", interactive=False) gpu_out = gr.Textbox(label="ZeroGPU 相容檢查", value="", interactive=False) with gr.Row(): preset = gr.Dropdown(list(PRESETS.keys()), label="範例一鍵填入", value="客服訊息分類") refresh_btn = gr.Button("重新整理狀態", size="sm") gpu_btn = gr.Button("GPU 相容檢查", size="sm") state = gr.Textbox(label="狀態文字(state)", lines=3) mode = gr.Radio(["是非題", "多選題"], label="題型", value="多選題") question = gr.Textbox(label="問題(instructions)", lines=1) options = gr.Textbox(label="候選選項(多選題用,每行「名稱: 描述」)", lines=4) btn = gr.Button("送出判別", variant="primary") out_summary = gr.Textbox(label="判別結果") out_bars = gr.Textbox(label="機率分佈", elem_classes="bar", lines=6) out_meta = gr.Textbox(label="效能", lines=1) preset.change(load_preset, preset, [state, mode, question, options]) refresh_btn.click(lambda: status_text(), outputs=status_box) gpu_btn.click(gpu_compat_check, outputs=gpu_out) btn.click(decide, [state, mode, question, options], [out_summary, out_bars, out_meta, status_box]) demo.load(load_preset, preset, [state, mode, question, options]) demo.load(lambda: status_text(), outputs=status_box) if __name__ == "__main__": print("[poc] app.py v20261003-zerogpu (multi-source backend + @spaces.GPU)", flush=True) print(f"[poc] 資料目錄:{DATA}({'重啟後保留,可斷點續傳' if _DATA_PERSISTENT else '重啟後消失,請勿重啟'})", flush=True) # Spaces 啟動時先備好後端(背景執行,不擋 UI) ensure_backend_if_needed() demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)), css=CSS)