File size: 21,044 Bytes
7337389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0739e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7337389
 
 
 
 
 
 
 
 
 
 
 
 
b8740b1
7337389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0739e8
7337389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15102c9
7337389
15102c9
7337389
 
15102c9
 
7337389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
# -*- coding: utf-8 -*-
"""
Lõi của demo NamBoQA: truy hồi + đọc hiểu, bám sát thực nghiệm trong bài.

  Truy hồi   BM25 (pyvi) · Bi-encoder · Hybrid  (alpha * norm(BM25) + (1-alpha) * norm(dense))
  Đọc hiểu   Extractive (XLM-R SQuAD2, ngưỡng từ chối) · Generative (Gemini, có grounding)

Code tokenize, chuẩn hoá điểm, prompt Gemini, nhận diện câu từ chối được lấy
nguyên từ nambo_run/code/run_experiments.py và run_reading.py (bộ thực nghiệm
25/10/2026, dữ liệu vòng 3) để demo cho ra đúng hành vi đã báo cáo trong bài.
Cách chia train/dev/test dùng chung chia_tap.py: theo địa điểm, phân tầng theo tỉnh.
"""

from __future__ import annotations

import json
import logging
import os
import re
import threading
import time
import unicodedata
from pathlib import Path

import numpy as np

log = logging.getLogger("namboqa")

NO_ANSWER = "Tài liệu không có thông tin để trả lời."
# chỉ dùng để chọn từ tô sáng trên giao diện, không ảnh hưởng truy hồi
STOPWORDS = {"người", "sau", "khi", "nơi", "được", "của", "những", "các", "này", "đã", "và",
             "là", "có", "cho", "với", "trong", "vào", "nào", "gì", "đến", "năm", "bao", "nhiêu",
             "đâu", "không", "từ", "theo", "tại", "một", "như", "thì", "mà", "nên", "hay", "hoặc",
             "đó", "ở", "để", "về", "ra", "lại", "còn", "rằng", "thời", "gian", "bởi", "do"}
DATA_FILES = ("passages.jsonl", "places_clean.json", "qa_final.jsonl")


# ------------------------------------------------------------------ tiện ích
def load_jsonl(p: Path):
    with open(p, encoding="utf-8") as f:
        return [json.loads(l) for l in f if l.strip()]


def vi_tokens(s: str):
    from pyvi import ViTokenizer
    s = unicodedata.normalize("NFC", str(s).lower())
    s = re.sub(r"[^\w\s]", " ", s)
    return ViTokenizer.tokenize(s).split()


def norm(x):
    x = np.asarray(x, dtype=float)
    return (x - x.min()) / (x.max() - x.min() + 1e-9)


REFUSAL_RE = re.compile(
    r"(không|chưa)\s+(\w+\s+){0,2}"
    r"(có|nêu|đề\s*cập|nhắc|nói|cung\s*cấp|chứa|tìm\s*thấy|xác\s*định|ghi|thể)"
    r"|no[_\s]?answer", re.I)


def is_refusal(pred: str) -> bool:
    p = (pred or "").strip().lower()
    return (not p) or bool(REFUSAL_RE.search(p))


PROMPT_GEN = """Bạn trả lời câu hỏi CHỈ dựa vào tài liệu dưới đây, không dùng kiến thức bên ngoài.

Tài liệu:
<<<
{context}
>>>

Câu hỏi: {question}

Nếu tài liệu không chứa thông tin để trả lời, hãy trả lời đúng một câu: "{no_answer}"
Nếu có, trả lời NGẮN GỌN (tên riêng, năm, con số, cụm từ), không giải thích thêm.

Đáp án:"""


def cpu_threads() -> int:
    """Số luồng CPU thật sự được cấp. os.cpu_count() trong container HF trả về số nhân
    của cả máy chủ (vài chục) trong khi Space CPU basic chỉ có 2 vCPU: đặt theo đó làm
    torch tranh chấp luồng, mỗi câu hỏi chậm 5–10 giây. Đọc hạn mức cgroup thay vào."""
    if os.environ.get("TORCH_NUM_THREADS"):
        return max(1, int(os.environ["TORCH_NUM_THREADS"]))
    try:
        quota, period = open("/sys/fs/cgroup/cpu.max").read().split()[:2]
        if quota != "max":
            return max(1, int(int(quota) / int(period)))
    except Exception:
        pass
    try:
        return max(1, len(os.sched_getaffinity(0)))
    except Exception:
        return max(1, os.cpu_count() or 1)


# ------------------------------------------------------------------ dữ liệu
def ensure_data(data_dir: Path):
    """Dữ liệu KHÔNG nằm trong repo Space (passages.jsonl không được phát hành
    nguyên văn). Nếu thiếu thì tải từ một HF Dataset private bằng HF_TOKEN."""
    missing = [f for f in DATA_FILES if not (data_dir / f).exists()]
    if not missing:
        return
    repo = os.environ.get("DATA_REPO")
    if not repo:
        raise RuntimeError(f"Thiếu {missing} trong {data_dir} và chưa đặt DATA_REPO")
    from huggingface_hub import snapshot_download
    log.info(f"Tải dữ liệu từ dataset private {repo} ...")
    snapshot_download(repo_id=repo, repo_type="dataset", local_dir=str(data_dir),
                      token=os.environ.get("HF_TOKEN") or None)


class Engine:
    def __init__(self, data_dir: str | Path):
        self.data_dir = Path(data_dir)
        self.retriever_name = os.environ.get(
            "RETRIEVER_MODEL", "bkai-foundation-models/vietnamese-bi-encoder")
        self.reader_name = os.environ.get("READER_MODEL", "deepset/xlm-roberta-base-squad2")
        self.gemini_model = os.environ.get("GEMINI_MODEL", "gemini-3.5-flash")
        self.alpha = float(os.environ.get("HYBRID_ALPHA", "0.5"))       # chọn trên dev trong bài
        self.reject_th = float(os.environ.get("REJECT_THRESHOLD", "0.0"))  # chọn trên dev trong bài
        self.max_ctx = int(os.environ.get("MAX_CONTEXT_CHARS", "3000"))
        # Số đoạn truy hồi ghép làm ngữ cảnh cho từng bộ đọc. Bài (Bảng 11): bộ trích
        # xuất đạt F1 cao nhất với 1–3 đoạn, ghép 5 đoạn thì F1 và tỉ lệ từ chối đúng
        # đều giảm; mô hình sinh chịu nhiễu tốt nên giữ 5 như thực nghiệm chính.
        self.ctx_k = {"extractive": int(os.environ.get("EXTRACTIVE_CONTEXT_K", "3")),
                      "generative": int(os.environ.get("GENERATIVE_CONTEXT_K", "5"))}
        self.status = {"data": "pending", "bm25": "pending", "dense": "pending",
                       "extractive": "pending", "generative": "pending"}
        self.errors: dict[str, str] = {}
        self._lock = threading.Lock()

    # ------------------------------------------------------------- khởi động
    def load_fast(self):
        """Phần nhẹ, chạy đồng bộ: dữ liệu + BM25 (~5 giây)."""
        ensure_data(self.data_dir)
        d = self.data_dir
        self.passages = load_jsonl(d / "passages.jsonl")
        self.places = {p["place_id"]: p for p in
                       json.loads((d / "places_clean.json").read_text(encoding="utf-8"))}
        self.qa = load_jsonl(d / "qa_final.jsonl")
        self.place_split = self._split()
        self.ids = [p["id"] for p in self.passages]
        self.txts = [p["text"] for p in self.passages]
        self.pid2idx = {pid: i for i, pid in enumerate(self.ids)}
        self.q_index = {self._qkey(q["question"]): q for q in self.qa}
        self.status["data"] = "ready"

        from rank_bm25 import BM25Okapi
        t0 = time.time()
        self.bm25 = BM25Okapi([vi_tokens(t) for t in self.txts])
        self.status["bm25"] = "ready"
        log.info(f"BM25 xong: {len(self.txts)} passage, {time.time() - t0:.1f}s")

        has_key = bool(os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY"))
        self.status["generative"] = "ready" if has_key else "disabled"

    def _split(self):
        """Tái lập đúng lần chia trong bài: chia_tap.chia_theo_tinh, seed 42 (1.416 / 321 / 708 câu)."""
        import chia_tap
        pids = {p["id"] for p in self.passages}
        qa = [{**q, "gold": [g for g in q["gold_passage_ids"] if g in pids]} for q in self.qa]
        qa = [q for q in qa if q["gold"]]
        prov = {pid: p.get("province") or "?" for pid, p in self.places.items()}
        _, _, _, tk = chia_tap.chia_theo_tinh(qa, prov)
        out = {}
        for name in ("train", "dev", "test"):
            for pid in tk[f"{name}_p"]:
                out[pid] = name
        return out

    def _sources(self, p):
        """Nguồn tư liệu để người xem tự kiểm: link cổng du lịch (theo đoạn) hoặc bài Wikipedia (theo địa điểm)."""
        m = p.get("meta") or {}
        out = []
        for u in (m.get("portal_urls") or ([m["source_url"]] if m.get("source_url") else []))[:2]:
            out.append({"label": u.split("/")[2] if "//" in u else u, "url": u})
        for t in (m.get("wiki_titles") or [])[:2]:
            out.append({"label": f"Wikipedia: {t}",
                        "url": "https://vi.wikipedia.org/wiki/" + t.replace(" ", "_")})
        return out

    def load_heavy(self):
        """Phần nặng, chạy nền: bi-encoder + mô hình extractive."""
        self._load_dense()
        if os.environ.get("DISABLE_EXTRACTIVE", "0") == "1":
            self.status["extractive"] = "disabled"
        else:
            self._load_extractive()

    def _load_dense(self):
        try:
            self.status["dense"] = "loading"
            import torch
            from sentence_transformers import SentenceTransformer
            torch.set_num_threads(cpu_threads())
            self.encoder = SentenceTransformer(self.retriever_name, device="cpu")
            safe = re.sub(r"[^\w.-]", "_", self.retriever_name)
            cache = self.data_dir / f"emb_{safe}.npy"
            if cache.exists():
                emb = np.load(cache)
                if emb.shape[0] != len(self.txts):
                    emb = None
            else:
                emb = None
            if emb is None:
                t0 = time.time()
                log.info("Mã hoá corpus bằng bi-encoder (lần đầu, vài phút trên CPU)...")
                emb = self.encoder.encode(self.txts, batch_size=32, normalize_embeddings=True,
                                          convert_to_numpy=True, show_progress_bar=False)
                try:
                    np.save(cache, emb.astype(np.float32))
                except Exception:
                    pass
                log.info(f"Mã hoá xong trong {time.time() - t0:.0f}s")
            self.emb = emb.astype(np.float32)
            self.status["dense"] = "ready"
        except Exception as e:
            log.exception("không nạp được bi-encoder")
            self.status["dense"] = "error"
            self.errors["dense"] = str(e)[:300]

    def _load_extractive(self):
        try:
            self.status["extractive"] = "loading"
            import torch
            from transformers import AutoModelForQuestionAnswering, AutoTokenizer
            self.qa_tok = AutoTokenizer.from_pretrained(self.reader_name)
            self.qa_model = AutoModelForQuestionAnswering.from_pretrained(self.reader_name).eval()
            self._torch = torch
            self.status["extractive"] = "ready"
            log.info(f"Mô hình extractive: {self.reader_name}")
        except Exception as e:
            log.exception("không nạp được mô hình extractive")
            self.status["extractive"] = "error"
            self.errors["extractive"] = str(e)[:300]

    # ------------------------------------------------------------- truy hồi
    @staticmethod
    def _qkey(q: str) -> str:
        return re.sub(r"\s+", " ", unicodedata.normalize("NFC", q or "").strip().lower())

    def retrieve(self, question: str, method: str = "hybrid", k: int = 5, alpha: float | None = None):
        """Trả về (danh sách (idx, điểm), phương án thực sự dùng)."""
        alpha = self.alpha if alpha is None else alpha
        if method in ("dense", "hybrid") and self.status["dense"] != "ready":
            method = "bm25"                      # bi-encoder chưa nạp xong -> lùi về BM25
        bm = self.bm25.get_scores(vi_tokens(question)) if method != "dense" else None
        if method != "bm25":
            qv = self.encoder.encode([question], normalize_embeddings=True, convert_to_numpy=True)[0]
            dn = self.emb @ qv.astype(np.float32)
        if method == "bm25":
            s = bm
        elif method == "dense":
            s = dn
        else:
            s = alpha * norm(bm) + (1 - alpha) * norm(dn)
        top = np.argsort(-s)[:k]
        return [(int(i), float(s[i])) for i in top], method

    # ------------------------------------------------------------- đọc hiểu
    def extract(self, question: str, context: str, max_len=384, stride=128, max_answer_len=40):
        """Rút span kiểu SQuAD v2 (so với xác suất token CLS). Giống make_extractor
        trong run_reading.py nhưng vector hoá phép tìm cặp (start, end) cho nhanh trên CPU."""
        torch = self._torch
        tok = self.qa_tok
        enc = tok(question, context, truncation="only_second", max_length=max_len, stride=stride,
                  return_overflowing_tokens=True, return_offsets_mapping=True,
                  padding="max_length", return_tensors="pt")
        offsets = enc.pop("offset_mapping")
        enc.pop("overflow_to_sample_mapping", None)
        dev = next(self.qa_model.parameters()).device      # CPU trên Space, GPU khi đánh giá
        with torch.no_grad():
            out = self.qa_model(**{k: v.to(dev) for k, v in enc.items()})
        best = {"answer": "", "score": 0.0, "start": -1, "end": -1}
        for w in range(out.start_logits.size(0)):
            ps = torch.softmax(out.start_logits[w], -1).float().cpu().numpy()
            pe = torch.softmax(out.end_logits[w], -1).float().cpu().numpy()
            null = float(ps[0] * pe[0])
            sid = enc.sequence_ids(w)
            valid = np.array([i for i in range(len(sid)) if sid[i] == 1])
            if not len(valid):
                continue
            lo, hi = valid[0], valid[-1]
            m = np.outer(ps[lo:hi + 1], pe[lo:hi + 1])
            m = np.triu(m) - np.triu(m, max_answer_len)     # j >= i và j < i + max_answer_len
            i, j = np.unravel_index(np.argmax(m), m.shape)
            sc = float(m[i, j])
            if sc > best["score"] and sc > null:
                a, b = offsets[w][lo + i][0].item(), offsets[w][lo + j][1].item()
                best = {"answer": context[a:b], "score": sc, "start": a, "end": b}
        return best

    def generate(self, question: str, context: str) -> str:
        from google import genai
        from google.genai import types
        client = genai.Client()
        r = client.models.generate_content(
            model=self.gemini_model,
            contents=PROMPT_GEN.format(context=context, question=question, no_answer=NO_ANSWER),
            config=types.GenerateContentConfig(temperature=0.0, max_output_tokens=2048),
        )
        txt = ""
        try:
            for part in r.candidates[0].content.parts:
                if getattr(part, "thought", False):
                    continue
                txt += getattr(part, "text", "") or ""
        except Exception:
            txt = r.text or ""
        txt = (txt or "").strip()
        txt = re.sub(r"^(đáp án|answer)\s*[::]\s*", "", txt, flags=re.I)
        return txt.split("\n")[0].strip().strip('"').rstrip(".")

    # ------------------------------------------------------------- pipeline
    def ask(self, question: str, method="hybrid", reader="generative", k=5, alpha=None):
        t0 = time.time()
        hits, used = self.retrieve(question, method, k, alpha)
        t_ret = time.time() - t0

        # ghép ngữ cảnh đúng như thực nghiệm: nối top-k rồi cắt max_ctx ký tự
        n_ctx = min(len(hits), self.ctx_k.get(reader, len(hits)))
        bounds, parts, pos = [], [], 0
        for idx, _ in hits[:n_ctx]:
            t = self.txts[idx]
            bounds.append((pos, pos + len(t), idx))
            parts.append(t)
            pos += len(t) + 1
        context = " ".join(parts)[: self.max_ctx]

        answer, score, src, span = None, None, None, None
        reader_used = reader
        t1 = time.time()
        if reader == "extractive":
            if self.status["extractive"] != "ready":
                raise RuntimeError(f"Mô hình extractive chưa sẵn sàng ({self.status['extractive']})")
            r = self.extract(question, context)
            score = r["score"]
            if r["answer"] and score >= self.reject_th:
                answer = r["answer"].strip()
                for a, b, idx in bounds:
                    if a <= r["start"] < b:
                        src, span = self.ids[idx], [r["start"] - a, r["end"] - a]
                        break
            else:
                answer = NO_ANSWER
        elif reader == "generative":
            if self.status["generative"] != "ready":
                raise RuntimeError("Chưa cấu hình GEMINI_API_KEY trên back-end")
            try:
                answer = self.generate(question, context)
            except Exception as e:
                raise RuntimeError(f"Gemini không trả lời được (kiểm tra GEMINI_API_KEY, quota, "
                                   f"tên mô hình GEMINI_MODEL): {str(e)[:160]}")
            if not is_refusal(answer):
                for _, _, idx in bounds:          # tìm đoạn chứa nguyên văn đáp án để tô sáng
                    p = self.txts[idx].lower().find(answer.lower())
                    if p >= 0 and len(answer) >= 2:
                        src, span = self.ids[idx], [p, p + len(answer)]
                        break
        else:
            reader_used = "none"
        t_read = time.time() - t1

        qwords = sorted({w.replace("_", " ") for w in vi_tokens(question)
                         if len(w) > 2 and w not in STOPWORDS},
                        key=len, reverse=True)
        max_chars = int(os.environ.get("MAX_PASSAGE_CHARS", "0"))
        passages = []
        for rank, (idx, sc) in enumerate(hits, 1):
            p = self.passages[idx]
            pl = self.places.get(p["place_id"], {})
            text = p["text"] if not max_chars else p["text"][:max_chars]
            passages.append({
                "rank": rank, "id": p["id"], "place_id": p["place_id"],
                "title": p.get("title") or pl.get("name"), "province": pl.get("province"),
                "heritage_level": pl.get("heritage_level"), "score": round(sc, 4),
                "text": text, "sources": self._sources(p),
                "in_context": reader != "none" and rank <= n_ctx and bounds[rank - 1][0] < self.max_ctx,
            })

        gold = None
        q = self.q_index.get(self._qkey(question))
        if q:
            gold = {"id": q["id"], "type": q["type"], "answer": q["answer"],
                    "gold_passage_ids": q["gold_passage_ids"],
                    "split": self.place_split.get(q["place_id"], "?"),
                    "round": "vòng 3" if q.get("vong_duyet") == 3 else "vòng 1–2",
                    "evaluated": q["type"] != "compare"}

        return {
            "question": question, "answer": answer,
            "refused": bool(answer is not None and is_refusal(answer)),
            "reader": reader_used, "reader_model": (self.reader_name if reader_used == "extractive"
                                                    else self.gemini_model if reader_used == "generative"
                                                    else None),
            "score": score, "source_passage_id": src, "answer_span": span,
            "retriever": used, "retriever_requested": method,
            "alpha": (self.alpha if alpha is None else alpha) if used == "hybrid" else None,
            "context_k": n_ctx if reader_used != "none" else 0,
            "passages": passages, "query_terms": qwords, "gold": gold,
            "timing_ms": {"retrieval": round(t_ret * 1000), "reading": round(t_read * 1000)},
        }

    # ------------------------------------------------------------- tiện ích UI
    def samples(self, n=6, qtype=None, split="test", seed=None):
        import random
        # mặc định bỏ "compare": bài không chấm loại này (13 câu test, quá ít)
        pool = [q for q in self.qa
                if (q["type"] == qtype if qtype else q["type"] != "compare")
                and (split == "all" or self.place_split.get(q["place_id"]) == split)]
        rnd = random.Random(seed)
        pick = rnd.sample(pool, min(n, len(pool)))
        return [{"id": q["id"], "type": q["type"], "question": q["question"],
                 "round": "vòng 3" if q.get("vong_duyet") == 3 else "vòng 1–2",
                 "place": self.places.get(q["place_id"], {}).get("name"),
                 "province": self.places.get(q["place_id"], {}).get("province")} for q in pick]

    def stats(self):
        from collections import Counter
        return {
            "n_passages": len(self.passages), "n_places": len(self.places), "n_qa": len(self.qa),
            "qa_by_type": dict(Counter(q["type"] for q in self.qa)),
            "qa_by_split": dict(Counter(self.place_split.get(q["place_id"], "?") for q in self.qa)),
            "places_by_province": dict(Counter(p.get("province") for p in self.places.values())),
            "models": {"retriever": self.retriever_name, "reader": self.reader_name,
                       "generative": self.gemini_model, "alpha": self.alpha,
                       "context_k": self.ctx_k},
        }