File size: 7,466 Bytes
f5bc4ee
d10910c
 
 
 
0ea7820
d10910c
0ea7820
d10910c
f5bc4ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ea7820
d10910c
 
 
 
0ea7820
 
d10910c
 
 
 
0ea7820
 
 
 
 
 
 
 
d10910c
 
3cdb5a1
 
 
 
d10910c
 
0ea7820
d10910c
 
 
 
 
 
 
 
0ea7820
 
d10910c
 
0ea7820
 
 
 
 
d10910c
 
 
0ea7820
d10910c
0ea7820
 
 
 
 
 
 
 
 
 
 
d10910c
0ea7820
 
d10910c
0ea7820
d10910c
 
 
 
 
f5bc4ee
 
 
0ea7820
d10910c
 
f5bc4ee
d10910c
 
 
 
 
f5bc4ee
d10910c
 
f5bc4ee
 
d10910c
f5bc4ee
d10910c
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
import os, json, time, collections, os.path as op, threading, urllib.request, urllib.error
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles

app = FastAPI()
KEY = os.environ.get("ANTHROPIC_API_KEY", "")
MODEL = os.environ.get("KNOU_MODEL", "claude-haiku-4-5-20251001")

# ================= ZTC (zero-token answer verification, in-process on CPU) =================
ZTC_REPO = os.environ.get("ZTC_REPO", "FINAL-Bench/ZTC-Judge-4B")
_ZT = {"ready": False, "err": None, "model": None, "tok": None, "cfg": None, "lin": None, "cv": None}

def _ztc_load():
    try:
        import numpy as np, torch
        from transformers import AutoModel, AutoTokenizer
        from huggingface_hub import snapshot_download
        p = snapshot_download(ZTC_REPO)
        cfg = json.load(open(op.join(p, "ztc_config.json"), encoding="utf-8"))
        lin = dict(np.load(op.join(p, cfg["probe_file"]), allow_pickle=True))
        cvp = op.join(p, "ztc_curve_probe_v2.npz")
        if not op.exists(cvp):
            cvp = op.join(p, cfg.get("curve_probe_file", "ztc_curve_probe.npz"))
        cv = dict(np.load(cvp))
        tok = AutoTokenizer.from_pretrained(p)
        if tok.pad_token is None:
            tok.pad_token = tok.eos_token
        model = AutoModel.from_pretrained(p, dtype=torch.bfloat16, low_cpu_mem_usage=True).eval()
        _ZT.update(model=model, tok=tok, cfg=cfg, lin=lin, cv=cv, ready=True)
    except Exception as e:
        _ZT["err"] = str(e)[:200]

threading.Thread(target=_ztc_load, daemon=True).start()

def ztc_score(question, answer):
    import numpy as np, torch
    cfg, tok, model, lin, cv = _ZT["cfg"], _ZT["tok"], _ZT["model"], _ZT["lin"], _ZT["cv"]
    text = cfg["template"] % ((question or "").strip(), (answer or "").strip())
    b = tok([text], return_tensors="pt", truncation=True, max_length=cfg["max_length"])
    with torch.no_grad():
        h = model(input_ids=b["input_ids"], attention_mask=b["attention_mask"]).last_hidden_state
    v = h[0, int(b["attention_mask"].sum()) - 1].float().numpy().astype(np.float64)
    s = float(((v - lin["mu"]) / lin["sd"]) @ lin["w"])
    z = (v - cv["mu"]) / cv["sd"]; a = cv["anchors"].astype(np.float64)
    d2 = np.sum(a * a, 1) + float(z @ z) - 2.0 * (a @ z)
    k = np.exp(-np.maximum(d2, 0.0) / (float(cv["med"]) * float(cv["gamma"])))
    c = float(k @ cv["alpha"].astype(np.float64))
    t = cfg["thresholds"]
    verdict = "๊ฒ€ํ†  ์š”๋ง" if s <= t["review"] else ("์ด์ƒ ์—†์Œ" if s >= t["clear"] else "ํŒ๋‹จ ๋ณด๋ฅ˜")
    return {"score": round(s, 3), "verdict": verdict, "curved": round(c, 3), "repo": ZTC_REPO, "generated_tokens": 0}

# ๋น„์šฉ ์•…์šฉ ๋ฐฉ์ง€: IP๋‹น ๋ถ„๋‹น ํ˜ธ์ถœ ์ œํ•œ (์ •์ƒ ์‚ฌ์šฉ์—” ์˜ํ–ฅ ์—†์Œ)
_HITS = collections.defaultdict(list)
_LIMIT = int(os.environ.get("KNOU_RATE_PER_MIN", "20"))
def _rate_ok(ip):
    now = time.time(); q = _HITS[ip]
    while q and now - q[0] > 60: q.pop(0)
    if len(q) >= _LIMIT: return False
    q.append(now); return True

SYS = (
    "๋„ˆ๋Š” ์ง€์‹๊ทธ๋ž˜ํ”„ ๊ธฐ๋ฐ˜ ์งˆ์˜์‘๋‹ต ์—”์ง„์ด๋‹ค. ์ œ๊ณต๋œ '์‚ฌ์‹ค(ํŠธ๋ฆฌํ”Œ)'๋งŒ ๊ทผ๊ฑฐ๋กœ ๋‹ตํ•œ๋‹ค. "
    "ํŠธ๋ฆฌํ”Œ์€ 'A โ€” ๊ด€๊ณ„ โ†’ B' ํ˜•์‹์ด๋ฉฐ ๋‹ค์Œ์ฒ˜๋Ÿผ ์ž์—ฐ์–ด๋กœ ํ•ด์„ํ•ด ๋‹ต์„ ๊ตฌ์„ฑํ•œ๋‹ค: "
    "IsA=A๋Š” B์˜ ํ•œ ์ข…๋ฅ˜, UsedFor=A๋Š” B์— ์“ฐ์ž„, AtLocation=A๋Š” B์— ์žˆ์Œ, "
    "CapableOf=A๋Š” B๋ฅผ ํ•  ์ˆ˜ ์žˆ์Œ, MadeOf=A๋Š” B๋กœ ๋งŒ๋“ค์–ด์ง, HasA=A๋Š” B๋ฅผ ๊ฐ€์ง, "
    "HasProperty=A๋Š” Bํ•œ ์„ฑ์งˆ, PartOf=A๋Š” B์˜ ์ผ๋ถ€, Causes=A๋Š” B๋ฅผ ์ผ์œผํ‚ด, HasSubevent=A๋ฅผ ํ•˜๋ฉด B๊ฐ€ ์ผ์–ด๋‚จ, RelatedTo=A๋Š” B์™€ ๊ด€๋ จ. "
    "์งˆ๋ฌธ ๋Œ€์ƒ์— ๊ด€ํ•œ ์‚ฌ์‹ค๋“ค์„ ๋ฌถ์–ด ์ž์‹ ๊ฐ ์žˆ๊ฒŒ ์ง€์ •๋œ ์–ธ์–ด๋กœ 2~4๋ฌธ์žฅ์œผ๋กœ ๋‹ตํ•œ๋‹ค. "
    "๊ด€๊ณ„ ์ด๋ฆ„(IsA ๋“ฑ)์„ ๊ทธ๋Œ€๋กœ ๋‚˜์—ดํ•˜์ง€ ๋ง๊ณ  ์ž์—ฐ์Šค๋Ÿฌ์šด ๋ฌธ์žฅ์œผ๋กœ ํ’€์–ด ์“ด๋‹ค. "
    "๋ณด๋„ ๊ธฐ์‚ฌ(๊ธฐ์‚ฌ ์ œ๋ชฉ์ด ์‚ฌ์‹ค๋กœ ์ฃผ์–ด์ง)์˜ ๊ฒฝ์šฐ ์ œ๋ชฉ ๋‚ด์šฉ์„ ๊ทผ๊ฑฐ๋กœ ์‚ฌ์‹ค๋งŒ ์š”์•ฝํ•œ๋‹ค. "
    "์ •๋ง๋กœ ๊ด€๋ จ ์‚ฌ์‹ค์ด ํ•˜๋‚˜๋„ ์—†์„ ๋•Œ๋งŒ ํ•ด๋‹น ์–ธ์–ด๋กœ '๊ด€๋ จ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค'๋ผ๊ณ  ๋‹ตํ•œ๋‹ค. ์—†๋Š” ์‚ฌ์‹ค์„ ์ง€์–ด๋‚ด์ง€ ์•Š๋Š”๋‹ค."
)

def _ip(req):
    fwd = req.headers.get("x-forwarded-for")
    return (fwd.split(",")[0].strip() if fwd else (req.client.host if req.client else "x")) or "x"

@app.get("/health")
def health():
    return {"ok": True, "has_key": bool(KEY), "model": MODEL}

@app.post("/ask")
async def ask(req: Request):
    try:
        data = await req.json()
    except Exception:
        return JSONResponse({"answer": "์š”์ฒญ ํ˜•์‹ ์˜ค๋ฅ˜."})
    q = (str(data.get("query") or "")).strip()[:600]
    facts = data.get("facts") or []
    facts = [str(f)[:200] for f in facts][:60]
    lang = str(data.get("lang") or "ko")[:2]
    langname = {"ko": "ํ•œ๊ตญ์–ด(Korean)", "en": "English", "zh": "ไธญๆ–‡(Chinese)"}.get(lang, "ํ•œ๊ตญ์–ด(Korean)")
    ip = (req.client.host if req.client else "x")
    fwd = req.headers.get("x-forwarded-for")
    if fwd: ip = fwd.split(",")[0].strip()
    if not _rate_ok(ip):
        return JSONResponse({"answer": "์š”์ฒญ์ด ๋งŽ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”. / Rate limit, please retry shortly."})
    if not q:
        return JSONResponse({"answer": "..."})
    if not KEY:
        return JSONResponse({"answer": "(์„œ๋ฒ„์— LLM ํ‚ค๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. Space Secrets์— ANTHROPIC_API_KEY๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”.)"})
    factstr = "\n".join("- " + f for f in facts) if facts else "(๊ด€๋ จ ์‚ฌ์‹ค ์—†์Œ)"
    prompt = ("Facts retrieved from the knowledge graph:\n" + factstr + "\n\nQuestion: " + q +
              "\n\nAnswer ONLY from the facts above, in " + langname +
              ", in 2-4 sentences. If the facts are insufficient, say so briefly in " + langname + ". Do not invent facts.")
    body = json.dumps({
        "model": MODEL, "max_tokens": 400, "system": SYS,
        "messages": [{"role": "user", "content": prompt}],
    }).encode()
    r = urllib.request.Request(
        "https://api.anthropic.com/v1/messages", data=body,
        headers={"x-api-key": KEY, "anthropic-version": "2023-06-01", "content-type": "application/json"},
    )
    try:
        resp = urllib.request.urlopen(r, timeout=45)
        d = json.load(resp)
        text = "".join(b.get("text", "") for b in d.get("content", []) if b.get("type") == "text")
        return JSONResponse({"answer": text or "(๋นˆ ์‘๋‹ต)"})
    except urllib.error.HTTPError as e:
        return JSONResponse({"answer": "(LLM ์˜ค๋ฅ˜ " + str(e.code) + ")"})
    except Exception:
        return JSONResponse({"answer": "(LLM ํ˜ธ์ถœ ์‹คํŒจ)"})

@app.get("/ztc_status")
def ztc_status():
    return {"ready": _ZT["ready"], "err": _ZT["err"], "repo": ZTC_REPO}

@app.post("/verify")
async def verify_ep(req: Request):
    if not _rate_ok("v:" + _ip(req)):
        return JSONResponse({"ok": False, "error": "rate"})
    try:
        data = await req.json()
    except Exception:
        return JSONResponse({"ok": False})
    q = str(data.get("query") or "")[:600]; a = str(data.get("answer") or "")[:2000]
    if not q or not a:
        return JSONResponse({"ok": False})
    if not _ZT["ready"]:
        return JSONResponse({"ok": False, "error": "ztc_loading", "detail": _ZT["err"]})
    try:
        return JSONResponse({"ok": True, "ztc": ztc_score(q, a)})
    except Exception as e:
        return JSONResponse({"ok": False, "error": str(e)[:160]})

app.mount("/", StaticFiles(directory="static", html=True), name="static")