# model_utils.py
from typing import List, Optional, Any, Dict, Tuple, Mapping, Sequence
from difflib import SequenceMatcher
import hashlib
import html
import json
import re
import os
import shutil
# This app uses PyTorch only; disable TensorFlow path in transformers.
os.environ.setdefault("USE_TF", "0")
os.environ.setdefault("TRANSFORMERS_NO_TF", "1")
import gradio as gr
import numpy as np
import torch
from transformers import (
AutoTokenizer,
AutoModelForCausalLM,
StoppingCriteria,
StoppingCriteriaList,
)
from sentence_transformers import SentenceTransformer
from sentence_transformers.util import cos_sim
import qa_store
from answer_repair import (
build_consensus_structure_fallback,
build_extractive_evidence_answer as _build_repaired_extractive_answer,
component_is_supported,
evidence_span_for_component,
select_manual_near_miss_candidates,
)
from routing_policy import (
GLOSSARY_SEMANTIC_MARGIN,
GLOSSARY_SEMANTIC_THRESHOLD,
QA_GLOBAL_SEMANTIC_MARGIN,
QA_GLOBAL_SEMANTIC_THRESHOLD,
QA_SCOPED_NEAR_MATCH_EVIDENCE_MIN,
QA_SCOPED_SEMANTIC_MARGIN,
QA_SCOPED_SEMANTIC_THRESHOLD,
RAG_GLOBAL_MIN_SIMILARITY,
RAG_SCOPED_MIN_SIMILARITY,
SAFE_LAO_REFUSAL,
assess_retrieval_confidence,
extract_compound_components,
glossary_question_eligibility,
is_compound_question,
item_in_scope,
normalize_scope,
prepare_generated_answer_text,
scope_label,
semantic_acceptance,
significant_tokens,
split_compound_question,
strong_evidence_for_extractive_fallback,
validate_generated_answer,
)
from loader import (
load_curriculum,
load_manual_qa,
manual_qa_write_lock,
rebuild_combined_qa,
load_glossary,
sync_download_manual_qa,
sync_download_cache,
sync_upload_data_tree,
sync_upload_cache,
CACHE_PATH,
)
# -----------------------------
# Base chat model
# -----------------------------
MODEL_NAME = "SeaLLMs/SeaLLMs-v3-1.5B-Chat"
EMBED_MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CACHE_FILE = os.path.join(BASE_DIR, "data", "cached_embeddings.pt")
CACHE_DOWNLOAD_FILE = os.path.join(BASE_DIR, "data", "cached_embeddings.download.pt")
CACHE_SCHEMA_VERSION = 2
GENERATION_PROMPT_VERSION = "natural-science-rag-v3-output-guarded"
MAX_GENERATED_TOKENS = 160
GENERATION_DO_SAMPLE = False
GENERATION_TEMPERATURE = 1.0
GENERATION_TOP_P = 1.0
OUTPUT_MIN_QUESTION_SEMANTIC_SIMILARITY = 0.32
device = "cuda" if torch.cuda.is_available() else "cpu"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
dtype = torch.float16 if torch.cuda.is_available() else torch.float32
model = AutoModelForCausalLM.from_pretrained(MODEL_NAME, torch_dtype=dtype)
model.to(device)
model.eval()
model.generation_config.do_sample = GENERATION_DO_SAMPLE
model.generation_config.temperature = GENERATION_TEMPERATURE
model.generation_config.top_p = GENERATION_TOP_P
embed_model = SentenceTransformer(EMBED_MODEL_NAME)
embed_model = embed_model.to(device)
# Number of textbook entries to include in the RAG context
MAX_CONTEXT_ENTRIES = 4
MIN_QA_QUERY_CHARS = 12
GUIDE_SUGGESTION_LIMIT: Optional[int] = None
BROAD_QA_LIMIT = 4
BROAD_CONTEXT_LIMIT = 2
BROAD_MIN_EMBED_SIM = 0.46
GUIDE_WELCOME_MESSAGE = (
"ເພື່ອຄວາມໄວ ແລະ ຕອບໄດ້ຕົງກັບປຶ້ມແບບຮຽນ ກະລຸນາລະບຸຊັ້ນຮຽນຂອງທ່ານ:\n\n"
"ມ.1\n"
"ມ.2\n"
"ມ.3\n"
"ມ.4"
)
_LAO_TO_ARABIC_DIGITS = str.maketrans("໐໑໒໓໔໕໖໗໘໙", "0123456789")
# ------------------------------------------------------------
# Helpers: YES/NO follow-up handling (context linking)
# ------------------------------------------------------------
_YES_SET = {
"ແມ່ນ", "ແມ່ນແລ້ວ", "ແມ່ນນະ", "ແມ່ນເດ", "ແມ່ນເດີ",
"ok", "okay", "yes", "y"
}
_NO_SET = {
"ບໍ່", "ບໍ່ແມ່ນ", "ບໍ່ແມ່ນເດ", "no", "n"
}
def _is_yes_no_reply(text: str) -> bool:
t = (text or "").strip().lower()
return t in _YES_SET or t in _NO_SET
def _extract_last_user_text(history: Any) -> Optional[str]:
"""
Gradio ChatInterface usually passes history as:
List[Tuple[user_msg, bot_msg]]
But we support a few other shapes just in case.
"""
if not history:
return None
if isinstance(history, list):
for item in reversed(history):
if item is None:
continue
if isinstance(item, (tuple, list)) and len(item) >= 1:
u = item[0]
if isinstance(u, str) and u.strip():
return u.strip()
if isinstance(item, dict):
if item.get("role") == "user":
c = item.get("content")
if isinstance(c, str) and c.strip():
return c.strip()
return None
# -----------------------------
# Embedding builders
# -----------------------------
def _entry_embedding_texts() -> List[str]:
texts: List[str] = []
for e in qa_store.ENTRIES:
chapter = e.get("chapter_title", "") or e.get("chapter", "") or ""
section = e.get("section_title", "") or e.get("section", "") or ""
text = e.get("text", "") or ""
texts.append(f"{chapter}\n{section}\n{text}")
return texts
def _glossary_embedding_texts() -> List[str]:
return [
f"{item.get('term', '')} :: {item.get('definition', '')}"
for item in qa_store.GLOSSARY
]
def _hash_texts(texts: List[str]) -> str:
digest = hashlib.sha256()
for text in texts:
digest.update((text or "").encode("utf-8", errors="surrogatepass"))
digest.update(b"\0")
return digest.hexdigest()
def _cache_metadata(textbook_texts: List[str], glossary_texts: List[str]) -> dict:
return {
"schema_version": CACHE_SCHEMA_VERSION,
"embedding_model": EMBED_MODEL_NAME,
"textbook_count": len(textbook_texts),
"textbook_hash": _hash_texts(textbook_texts),
"glossary_count": len(glossary_texts),
"glossary_hash": _hash_texts(glossary_texts),
}
def _cache_len(value: Any) -> int:
if value is None:
return 0
return len(value)
def _cache_matches(cache: dict, expected: dict) -> bool:
metadata = cache.get("metadata")
if not isinstance(metadata, dict):
return False
keys = (
"schema_version",
"embedding_model",
"textbook_count",
"textbook_hash",
"glossary_count",
"glossary_hash",
)
if any(metadata.get(key) != expected.get(key) for key in keys):
return False
return (
_cache_len(cache.get("textbook")) == expected["textbook_count"]
and _cache_len(cache.get("glossary")) == expected["glossary_count"]
)
def _load_cache_from_path(path: str, expected: dict, label: str) -> bool:
if not os.path.exists(path):
return False
try:
print(f"[INFO] Loading {label} cached embeddings from {path}...")
# Cache is generated by this app, so loading full object graph is intentional.
cache = torch.load(path, map_location=device, weights_only=False)
except Exception as e:
print(f"[WARN] Failed to load {label} cache: {e}")
return False
if not isinstance(cache, dict) or not _cache_matches(cache, expected):
textbook_len = _cache_len(cache.get("textbook")) if isinstance(cache, dict) else 0
glossary_len = _cache_len(cache.get("glossary")) if isinstance(cache, dict) else 0
print(
"[WARN] Ignoring stale cache "
f"({label}: textbook={textbook_len}/{expected['textbook_count']}, "
f"glossary={glossary_len}/{expected['glossary_count']})."
)
return False
textbook = cache.get("textbook")
glossary = cache.get("glossary")
qa_store.TEXT_EMBEDDINGS = textbook.to(device) if textbook is not None else None
if isinstance(glossary, torch.Tensor):
glossary = glossary.detach().cpu().numpy()
qa_store.GLOSSARY_EMBEDDINGS = glossary
print("[INFO] Cached embeddings loaded successfully.")
return True
def _save_embedding_cache(metadata: dict) -> None:
try:
os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
tmp_path = f"{CACHE_PATH}.tmp"
torch.save(
{
"metadata": metadata,
"textbook": qa_store.TEXT_EMBEDDINGS,
"glossary": qa_store.GLOSSARY_EMBEDDINGS,
},
tmp_path,
)
os.replace(tmp_path, CACHE_PATH)
print(f"[INFO] Saved embedding cache to {CACHE_PATH}.")
except Exception as e:
print(f"[WARN] Could not save embedding cache: {e}")
def _compute_embeddings(textbook_texts: List[str], glossary_texts: List[str]) -> None:
if textbook_texts:
print("[INFO] Computing textbook embeddings from scratch...")
qa_store.TEXT_EMBEDDINGS = embed_model.encode(
textbook_texts,
convert_to_tensor=True,
show_progress_bar=False,
)
else:
qa_store.TEXT_EMBEDDINGS = None
if glossary_texts:
print("[INFO] Computing glossary embeddings from scratch...")
qa_store.GLOSSARY_EMBEDDINGS = embed_model.encode(
glossary_texts,
convert_to_numpy=True,
normalize_embeddings=True,
show_progress_bar=False,
)
else:
qa_store.GLOSSARY_EMBEDDINGS = None
def admin_force_rebuild_cache() -> str:
"""
Force recalculation of all embeddings and upload to cloud.
Triggered by Teacher Panel button.
"""
_refresh_runtime_data()
print("[ADMIN] Rebuilding Textbook Embeddings...")
textbook_texts = _entry_embedding_texts()
print("[ADMIN] Rebuilding Glossary Embeddings...")
glossary_texts = _glossary_embedding_texts()
_compute_embeddings(textbook_texts, glossary_texts)
print("[ADMIN] Saving to disk...")
_save_embedding_cache(_cache_metadata(textbook_texts, glossary_texts))
data_upload_status = sync_upload_data_tree()
upload_status = sync_upload_cache()
upload_status = f"{data_upload_status} | {upload_status}"
return (
"Cache rebuilt: "
f"textbook={len(textbook_texts)}, glossary={len(glossary_texts)} | "
f"{upload_status}"
)
def _build_cached_embeddings() -> None:
"""
Load matching cached embeddings, otherwise build and save a fresh cache.
"""
textbook_texts = _entry_embedding_texts()
glossary_texts = _glossary_embedding_texts()
expected = _cache_metadata(textbook_texts, glossary_texts)
if _load_cache_from_path(CACHE_FILE, expected, "local"):
return
if sync_download_cache(CACHE_DOWNLOAD_FILE):
if _load_cache_from_path(CACHE_DOWNLOAD_FILE, expected, "downloaded"):
shutil.copy(CACHE_DOWNLOAD_FILE, CACHE_FILE)
return
print("[WARN] Downloaded cache does not match current data; keeping local data authoritative.")
_compute_embeddings(textbook_texts, glossary_texts)
_save_embedding_cache(expected)
def _refresh_runtime_data() -> None:
"""
Re-read local curriculum/manual QA/glossary files so runtime state matches disk.
"""
with manual_qa_write_lock():
load_curriculum()
load_manual_qa()
load_glossary()
rebuild_combined_qa()
# -----------------------------
# Load data once at import time
# -----------------------------
if os.getenv("CHATBOT_SKIP_CLOUD_SYNC", "").strip().lower() not in {"1", "true", "yes", "on"}:
sync_download_manual_qa()
else:
print("[INFO] Skipping startup Manual Q&A cloud sync for a frozen local evaluation.")
load_curriculum()
load_manual_qa()
load_glossary()
rebuild_combined_qa()
_build_cached_embeddings()
# -----------------------------
# System prompt (Natural Science)
# -----------------------------
SYSTEM_PROMPT = (
"ທ່ານແມ່ນຜູ້ຊ່ວຍເຫຼືອດ້ານວິທະຍາສາດທໍາມະຊາດ "
"ສໍາລັບນັກຮຽນຊັ້ນ ມ.1-ມ.4. "
"ຕອບແຕ່ພາສາລາວ ໃຫ້ຕອບສັ້ນໆ 2–3 ປະໂຫຍກ ແລະເຂົ້າໃຈງ່າຍ. "
"ໃຫ້ອີງຈາກຂໍ້ມູນອ້າງອີງຂ້າງລຸ່ມນີ້ເທົ່ານັ້ນ. "
"ຕ້ອງຕອບໃຫ້ຄົບທຸກສ່ວນຂອງຄຳຖາມ ແລະ ຫ້າມທວນຄຳຖາມ. "
f"ຖ້າຂໍ້ມູນບໍ່ພຽງພໍ ໃຫ້ຕອບພຽງວ່າ: {SAFE_LAO_REFUSAL}"
)
# -----------------------------
# Helper: history formatting
# -----------------------------
def _format_history(history: Optional[List]) -> str:
"""
Convert last few chat turns into a Lao conversation snippet
to give the model context for follow-up questions.
Gradio history format: [[user_msg, bot_msg], [user_msg, bot_msg], ...]
"""
if not history:
return ""
recent = history[-3:]
lines: List[str] = []
for turn in recent:
if not isinstance(turn, (list, tuple)) or len(turn) != 2:
continue
user_msg, bot_msg = turn
lines.append(f"ນັກຮຽນ: {user_msg}")
lines.append(f"ອາຈານ AI: {bot_msg}")
if not lines:
return ""
return "\n".join(lines) + "\n\n"
def _to_ascii_digits(text: str) -> str:
return (text or "").translate(_LAO_TO_ARABIC_DIGITS)
def _extract_user_text(item: Any) -> Optional[str]:
if isinstance(item, (tuple, list)) and len(item) >= 1:
user = item[0]
if isinstance(user, str):
return user.strip()
if isinstance(item, dict) and item.get("role") == "user":
content = item.get("content")
if isinstance(content, str):
return content.strip()
return None
def _extract_bot_text(item: Any) -> Optional[str]:
if isinstance(item, (tuple, list)) and len(item) >= 2:
bot = item[1]
if isinstance(bot, str):
return bot.strip()
if isinstance(item, dict) and item.get("role") == "assistant":
content = item.get("content")
if isinstance(content, str):
return content.strip()
return None
def _parse_grade_selection(text: str) -> Optional[str]:
t = _to_ascii_digits((text or "").lower())
compact = re.sub(r"\s+", "", t)
m = re.search(r"(?:m|ມ)[\._-]?([1-4])", compact)
if not m:
return None
return f"M_{int(m.group(1))}"
def _unit_number(unit: str) -> Optional[int]:
m = re.search(r"(\d+)", unit or "")
if not m:
return None
try:
return int(m.group(1))
except ValueError:
return None
def _parse_unit_selection(text: str, history: Optional[List]) -> Optional[str]:
t = _to_ascii_digits((text or "").strip())
if not t:
return None
lower_t = t.lower()
m = re.search(r"\bu[_\-\s]?(\d{1,2})\b", lower_t)
if m:
return f"U_{int(m.group(1)):02d}"
m = re.search(r"(?:ບົດທີ|บทที่|chapter)\s*([0-9]{1,2})", lower_t)
if m:
return f"U_{int(m.group(1)):02d}"
# Accept plain numbers only right after chapter menu was shown.
if re.fullmatch(r"\d{1,2}", lower_t):
recent_bot_msgs: List[str] = []
if isinstance(history, list):
for item in history[-3:]:
bot_txt = _extract_bot_text(item)
if bot_txt:
recent_bot_msgs.append(bot_txt)
if any("ມີຢູ່" in msg and "ບົດ" in msg for msg in recent_bot_msgs):
return f"U_{int(lower_t):02d}"
return None
def _grade_display(grade: str) -> str:
m = re.search(r"(\d+)", grade or "")
return f"ມ.{m.group(1)}" if m else grade
def _load_unit_title_from_textbook(grade: str, unit: str) -> str:
"""
Read the unit title directly from textbook.jsonl on disk.
This covers cases where new curriculum files were added after app startup.
"""
if not grade or not unit or grade == "LEGACY":
return ""
textbook_path = os.path.join(BASE_DIR, "data", grade, unit, "textbook.jsonl")
if not os.path.exists(textbook_path):
return ""
try:
with open(textbook_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
obj = json.loads(line)
return (
str(obj.get("title") or "").strip()
or str(obj.get("chapter_title") or "").strip()
)
except Exception:
return ""
return ""
def _chapter_label(title: str, unit: str, chapter_num: Optional[int]) -> str:
clean_title = re.sub(r"\s+", " ", (title or "").strip())
if clean_title:
if re.search(r"(?:ບົດທີ|บทที่|chapter)\s*\d+", clean_title, flags=re.IGNORECASE):
return clean_title
if chapter_num is not None:
return f"ບົດທີ {chapter_num}: {clean_title}"
return clean_title
if chapter_num is not None:
return f"ບົດທີ {chapter_num}"
return unit
def _chapter_catalog_for_grade(grade: str) -> List[dict]:
by_unit: dict = {}
for e in qa_store.ENTRIES:
if e.get("grade") != grade:
continue
unit = str(e.get("unit") or "").strip()
if not unit or unit in by_unit:
continue
chapter_num: Optional[int] = None
try:
if e.get("chapter") is not None:
chapter_num = int(e.get("chapter"))
except Exception:
chapter_num = None
if chapter_num is None:
chapter_num = _unit_number(unit)
title = (
str(e.get("title") or "").strip()
or str(e.get("chapter_title") or "").strip()
)
if not title:
title = _load_unit_title_from_textbook(grade, unit)
by_unit[unit] = {
"unit": unit,
"chapter_num": chapter_num,
"label": _chapter_label(title, unit, chapter_num),
}
# Ensure units that only exist in manual QA are still listed.
for e in qa_store.MANUAL_QA_LIST:
if e.get("grade") != grade:
continue
unit = str(e.get("unit") or "").strip()
if not unit or unit in by_unit:
continue
unit_num = _unit_number(unit)
title = _load_unit_title_from_textbook(grade, unit)
by_unit[unit] = {
"unit": unit,
"chapter_num": unit_num,
"label": _chapter_label(title, unit, unit_num),
}
items = list(by_unit.values())
items.sort(key=lambda x: (x.get("chapter_num") is None, x.get("chapter_num") or 999, x["unit"]))
return items
def _build_chapter_menu_reply(grade: str) -> str:
chapters = _chapter_catalog_for_grade(grade)
grade_name = _grade_display(grade)
if not chapters:
return (
f"ຍັງບໍ່ພົບຂໍ້ມູນບົດຮຽນຂອງ {grade_name}.\n"
"ກະລຸນາຖາມໂດຍກົງ ຫຼື ກວດສອບໄຟລ໌ textbook.jsonl."
)
lines = [item["label"] for item in chapters]
menu = "\n".join(lines)
return (
f"ປຶ້ມແບບຮຽນ ວິທະຍາສາດທຳມະຊາດ ຊັ້ນ {grade_name} ມີຢູ່ {len(lines)} ບົດ:\n"
f"{menu}\n\n"
"ກະລຸນາເລືອກບົດທີ່ຕ້ອງການ (ຕົວຢ່າງ: ບົດທີ 5 ຫຼື U_05)."
)
def _looks_like_chapter_menu(text: str) -> bool:
return "\u0ea1\u0eb5\u0ea2\u0eb9\u0ec8" in (text or "") and "\u0e9a\u0ebb\u0e94" in (text or "")
def _latest_selected_grade(history: Optional[List]) -> Optional[str]:
if not isinstance(history, list):
return None
for item in reversed(history):
user_text = _extract_user_text(item)
if user_text:
grade = _parse_grade_selection(user_text)
if grade:
return grade
bot_text = _extract_bot_text(item)
if bot_text and _looks_like_chapter_menu(bot_text):
grade = _parse_grade_selection(bot_text)
if grade:
return grade
return None
def _grade_from_chapter_label(text: str, unit: Optional[str]) -> Optional[str]:
if not text or not unit:
return None
candidate = re.sub(r"\s+", " ", text.strip())
matches: List[str] = []
grades = sorted(
{
str(e.get("grade") or "")
for e in [*qa_store.ENTRIES, *qa_store.MANUAL_QA_LIST]
if e.get("grade")
},
key=lambda g: int(re.search(r"\d+", g).group(0)) if re.search(r"\d+", g) else 999,
)
for grade in grades:
for item in _chapter_catalog_for_grade(grade):
if item.get("unit") != unit:
continue
label = re.sub(r"\s+", " ", str(item.get("label") or "").strip())
if candidate == label or candidate.startswith(label) or label.startswith(candidate):
matches.append(grade)
unique_matches = sorted(set(matches))
return unique_matches[0] if len(unique_matches) == 1 else None
def _collect_unit_questions(grade: str, unit: str) -> List[str]:
seen = set()
out: List[str] = []
def add_question(q: str) -> None:
question = (q or "").strip()
if not question:
return
key = qa_store.normalize_question(question)
if key in seen:
return
seen.add(key)
out.append(question)
# Manual QA first (teacher curated).
for e in qa_store.MANUAL_QA_LIST:
if e.get("grade") == grade and e.get("unit") == unit:
add_question(str(e.get("q") or ""))
# Then textbook auto-qa if present.
for e in qa_store.ENTRIES:
if e.get("grade") != grade or e.get("unit") != unit:
continue
for pair in e.get("qa", []) or []:
if isinstance(pair, dict):
add_question(str(pair.get("q") or ""))
if GUIDE_SUGGESTION_LIMIT is None:
return out
return out[:GUIDE_SUGGESTION_LIMIT]
def _build_suggestion_reply(grade: str, unit: str) -> str:
catalog = _chapter_catalog_for_grade(grade)
label = next((c["label"] for c in catalog if c["unit"] == unit), unit)
questions = _collect_unit_questions(grade, unit)
if not questions:
return (
f"ຍັງບໍ່ພົບລາຍການຄຳຖາມແນະນຳສຳລັບ {label}.\n"
"ທ່ານສາມາດຖາມຄຳຖາມໂດຍກົງໄດ້."
)
question_lines = "\n".join(f"- {q}" for q in questions)
return (
"ຄຳຖາມທີ່ຖາມແລ້ວໄດ້ຮັບຄຳຕອບຕົງຕາມປຶ້ມແບບຮຽນ:\n"
f"{question_lines}\n\n"
f"(ຫົວຂໍ້: {label})"
)
def _guided_reply(message: str, history: Optional[List]) -> Optional[str]:
grade = _parse_grade_selection(message)
if grade:
return _build_chapter_menu_reply(grade)
unit = _parse_unit_selection(message, history)
if not unit:
return None
selected_grade = _latest_selected_grade(history) or _grade_from_chapter_label(message, unit) or "M_1"
chapter_units = {c["unit"] for c in _chapter_catalog_for_grade(selected_grade)}
if unit not in chapter_units:
return None
return _build_suggestion_reply(selected_grade, unit)
# -----------------------------
# RAG: retrieve textbook context
# -----------------------------
def retrieve_context_details(
question: str,
max_entries: int = MAX_CONTEXT_ENTRIES,
*,
grade: Any = None,
unit: Any = None,
allow_global_fallback: bool = True,
decision_trace: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""Return scoped textbook candidates for the production RAG route.
Grade/unit constraints are strict for the first pass. A global fallback is
used only when scoped evidence is absent or substantially weaker than a
strong global candidate. Unscored arbitrary entries are never treated as
retrieval evidence.
"""
if decision_trace is not None:
decision_trace.clear()
entries = list(getattr(qa_store, "ENTRIES", None) or [])
limit = min(max(int(max_entries or 0), 0), len(entries))
if not entries or limit == 0:
if decision_trace is not None:
decision_trace.update(
{
"scope_mode": scope_label(grade, unit),
"fallback_used": False,
"reason": "no_textbook_entries",
}
)
return []
if qa_store.TEXT_EMBEDDINGS is None:
if decision_trace is not None:
decision_trace.update(
{
"scope_mode": scope_label(grade, unit),
"fallback_used": False,
"reason": "missing_similarity_scores",
}
)
return []
q_vec = embed_model.encode(
question,
convert_to_tensor=True,
show_progress_bar=False,
)
sims = cos_sim(q_vec, qa_store.TEXT_EMBEDDINGS)[0]
def rank_indices(indices: Sequence[int], count: int) -> List[Tuple[int, float]]:
if not indices or count <= 0:
return []
candidate_indices = list(indices)
candidate_scores = sims[candidate_indices]
k = min(count, len(candidate_indices))
values, local_indices = torch.topk(candidate_scores, k=k)
return [
(candidate_indices[int(local_idx)], float(score))
for local_idx, score in zip(local_indices.tolist(), values.tolist())
]
all_indices = list(range(len(entries)))
scoped = bool(normalize_scope(grade) or normalize_scope(unit))
scoped_indices = [
idx for idx, entry in enumerate(entries) if item_in_scope(entry, grade, unit)
]
ranked = rank_indices(scoped_indices if scoped else all_indices, limit)
selected_scope = scope_label(grade, unit) if scoped else "global"
fallback_used = False
fallback_reason = ""
# Controlled fallback: require a strong global candidate that improves on
# the scoped top score by a meaningful margin. This avoids the historical
# behavior where a merely reordered cross-unit result still won.
if scoped and allow_global_fallback:
global_ranked = rank_indices(all_indices, limit)
scoped_top = ranked[0][1] if ranked else None
global_top = global_ranked[0][1] if global_ranked else None
if not ranked and global_top is not None and global_top >= 0.78:
ranked = global_ranked
fallback_used = True
fallback_reason = "no_scoped_entries_strong_global_candidate"
selected_scope = "global_fallback"
elif (
scoped_top is not None
and global_top is not None
and scoped_top < 0.57
and global_top >= 0.78
and global_top - scoped_top >= 0.12
):
ranked = global_ranked
fallback_used = True
fallback_reason = "weak_scoped_evidence_strong_global_candidate"
selected_scope = "global_fallback"
details: List[Dict[str, Any]] = []
for rank, (entry_index, score) in enumerate(ranked, start=1):
entry = entries[entry_index]
details.append(
{
"rank": rank,
"source_id": str(entry.get("id") or ""),
"source_type": "textbook",
"grade": str(entry.get("grade") or ""),
"unit": str(entry.get("unit") or ""),
"source_file": str(entry.get("_source_file") or ""),
"similarity_score": score,
"retrieved_text": str(entry.get("text") or ""),
# Keep the prompt-facing fields separate so formatting stays
# byte-for-byte compatible with the existing chatbot path.
"chapter_title": str(entry.get("chapter_title") or ""),
"section_title": str(entry.get("section_title") or ""),
"title": str(entry.get("title") or entry.get("chapter") or ""),
"section": str(entry.get("section") or ""),
"retrieval_scope": selected_scope,
}
)
if decision_trace is not None:
decision_trace.update(
{
"requested_scope": scope_label(grade, unit),
"scope_mode": selected_scope,
"requested_grades": list(normalize_scope(grade)),
"requested_units": list(normalize_scope(unit)),
"scoped_candidate_count": len(scoped_indices)
if scoped
else len(entries),
"fallback_used": fallback_used,
"fallback_reason": fallback_reason,
"reason": "ranked_candidates" if details else "no_ranked_candidates",
"top_candidates": [
{
"rank": item["rank"],
"source_id": item["source_id"],
"grade": item["grade"],
"unit": item["unit"],
"similarity_score": item["similarity_score"],
}
for item in details
],
}
)
return details
def format_retrieved_contexts(retrieved_contexts: List[Dict[str, Any]]) -> str:
"""Format structured production retrieval records for the SeaLLMs prompt."""
context_blocks: List[str] = []
for item in retrieved_contexts:
source_type = str(item.get("source_type") or "textbook")
component_labels = [
str(value)
for value in (item.get("component_labels") or [])
if str(value)
]
component_text = (
f", ອົງປະກອບ {', '.join(component_labels)}"
if component_labels
else ""
)
header = (
f"[ແຫຼ່ງ {source_type}, "
f"ລະຫັດ {item.get('source_id','')}, "
f"ຊັ້ນ {item.get('grade','')}, "
f"ໜ່ວຍ {item.get('unit','')}, "
f"ບົດ {item.get('chapter_title','')}, "
f"ຫົວຂໍ້ {item.get('section_title','')}{component_text}]"
)
context_blocks.append(f"{header}\n{item.get('retrieved_text','')}")
return "\n\n".join(context_blocks)
def retrieve_context(
question: str,
max_entries: int = MAX_CONTEXT_ENTRIES,
*,
grade: Any = None,
unit: Any = None,
) -> str:
"""
Embedding-based retrieval over textbook entries.
Falls back to concatenated raw knowledge if embeddings are missing.
"""
# Preserve the historical explicit-zero behavior (an empty context) while
# still falling back to RAW_KNOWLEDGE when no textbook entries exist.
if getattr(qa_store, "ENTRIES", None) and int(max_entries or 0) == 0:
return ""
details = retrieve_context_details(
question,
max_entries=max_entries,
grade=grade,
unit=unit,
)
if not details:
return getattr(qa_store, "RAW_KNOWLEDGE", "")
return format_retrieved_contexts(details)
# -----------------------------
# Glossary-based answering
# -----------------------------
def normalize_lao_text(text: str) -> str:
"""
Clean Lao text for accurate matching.
Removes punctuation and extra spaces.
"""
if not text:
return ""
text = text.lower().strip()
text = re.sub(r"[?.!,;։:'\"“”‘’]", "", text)
text = re.sub(r"\s+", " ", text)
return text.strip()
def _record_source_match(
match_trace: Optional[Dict[str, Any]],
item: Optional[Dict[str, Any]],
*,
source_type: str,
method: str,
score: Optional[float] = None,
) -> None:
"""Record evaluation provenance without changing the returned answer."""
if match_trace is None:
return
match_trace.update(
{
"accepted": True,
"rejection_reason": "",
"match_method": method,
"rank": 1,
"source_id": str((item or {}).get("id") or ""),
"source_type": source_type,
"grade": str((item or {}).get("grade") or ""),
"unit": str((item or {}).get("unit") or ""),
"source_file": str((item or {}).get("_source_file") or ""),
"similarity_score": score,
"matched_text": str(
(item or {}).get("q")
or (item or {}).get("term")
or ""
),
}
)
def answer_from_glossary(
message: str,
*,
grade: Any = None,
unit: Any = None,
match_trace: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
"""
Try to answer using the glossary index.
Tier 1: Exact/Substring match (Sorted by Length to fix overlap bugs).
Tier 2: Vector embedding match (Fallback).
"""
if match_trace is not None:
match_trace.clear()
match_trace.update(
{
"accepted": False,
"route": "glossary",
"scope": scope_label(grade, unit),
"requested_grades": list(normalize_scope(grade)),
"requested_units": list(normalize_scope(unit)),
"top_candidates": [],
}
)
eligible, eligibility_reason = glossary_question_eligibility(message)
if match_trace is not None:
match_trace.update(
{
"question_eligible": eligible,
"eligibility_reason": eligibility_reason,
}
)
if not eligible:
if match_trace is not None:
match_trace["rejection_reason"] = eligibility_reason
return None
if not getattr(qa_store, "GLOSSARY", None):
if match_trace is not None:
match_trace["rejection_reason"] = "no_glossary_entries"
return None
norm_msg = normalize_lao_text(message)
sorted_glossary = sorted(
[
item
for item in qa_store.GLOSSARY
if item_in_scope(item, grade, unit)
],
key=lambda x: len(normalize_lao_text(x.get("term", ""))),
reverse=True,
)
for item in sorted_glossary:
term_raw = item.get("term", "")
norm_term = normalize_lao_text(term_raw)
if not norm_term:
continue
is_exact = (norm_msg == norm_term)
is_substring = (norm_term in norm_msg) and (len(norm_msg) < len(norm_term) + 20)
if is_exact or is_substring:
_record_source_match(
match_trace,
item,
source_type="glossary",
method="exact" if is_exact else "substring",
score=1.0 if is_exact else None,
)
definition = (item.get("definition", "") or "").strip()
example = (item.get("example", "") or "").strip()
if example:
return f"{definition} ຕົວຢ່າງ: {example}"
return definition
if qa_store.GLOSSARY_EMBEDDINGS is None:
if match_trace is not None:
match_trace["rejection_reason"] = "glossary_embeddings_unavailable"
return None
q_emb = embed_model.encode(
[message],
convert_to_numpy=True,
normalize_embeddings=True,
)[0]
sims = np.dot(qa_store.GLOSSARY_EMBEDDINGS, q_emb)
ranked_indices = np.argsort(sims)[::-1].tolist()
if match_trace is not None:
match_trace["top_candidates"] = [
{
"rank": rank,
"source_id": str(qa_store.GLOSSARY[idx].get("id") or ""),
"grade": str(qa_store.GLOSSARY[idx].get("grade") or ""),
"unit": str(qa_store.GLOSSARY[idx].get("unit") or ""),
"similarity_score": float(sims[idx]),
"in_scope": item_in_scope(
qa_store.GLOSSARY[idx], grade, unit
),
"rejection_reason": ""
if item_in_scope(qa_store.GLOSSARY[idx], grade, unit)
else "scope_mismatch",
}
for rank, idx in enumerate(ranked_indices[:5], start=1)
]
valid_indices = [
idx
for idx in ranked_indices
if item_in_scope(qa_store.GLOSSARY[idx], grade, unit)
]
if not valid_indices:
if match_trace is not None:
match_trace["rejection_reason"] = "no_candidates_in_scope"
return None
best_idx = valid_indices[0]
best_sim = float(sims[best_idx])
second_sim = float(sims[valid_indices[1]]) if len(valid_indices) > 1 else None
accepted, acceptance_reason, margin = semantic_acceptance(
best_sim,
second_sim,
threshold=GLOSSARY_SEMANTIC_THRESHOLD,
minimum_margin=GLOSSARY_SEMANTIC_MARGIN,
)
if match_trace is not None:
match_trace.update(
{
"semantic_threshold": GLOSSARY_SEMANTIC_THRESHOLD,
"minimum_margin": GLOSSARY_SEMANTIC_MARGIN,
"similarity_margin": margin,
}
)
if not accepted:
if match_trace is not None:
match_trace["rejection_reason"] = acceptance_reason
return None
item = qa_store.GLOSSARY[best_idx]
_record_source_match(
match_trace,
item,
source_type="glossary",
method="semantic",
score=best_sim,
)
definition = (item.get("definition", "") or "").strip()
example = (item.get("example", "") or "").strip()
if example:
return f"{definition} ຕົວຢ່າງ: {example}"
return definition
# -----------------------------
# Prompt + LLM generation
# -----------------------------
def build_prompt(
question: str,
history: Optional[List] = None,
*,
retrieved_contexts: Optional[List[Dict[str, Any]]] = None,
) -> str:
if retrieved_contexts is None:
context = retrieve_context(question, max_entries=MAX_CONTEXT_ENTRIES)
elif retrieved_contexts:
context = format_retrieved_contexts(retrieved_contexts)
else:
# An explicit empty list means the retriever supplied no evidence.
# Never silently replace it with the entire global knowledge base.
context = "(ບໍ່ມີຂໍ້ມູນອ້າງອີງທີ່ຜ່ານເກນ)"
history_block = _format_history(history)
return f"""{SYSTEM_PROMPT}
{history_block}ຂໍ້ມູນອ້າງອີງ:
{context}
ຄຳຖາມ: {question}
ຄຳຕອບດ້ວຍພາສາລາວ:"""
class _StopOnGeneratedTokenSequences(StoppingCriteria):
"""Stop batch-size-one generation after prompt scaffolding reappears."""
def __init__(
self,
prompt_length: int,
token_sequences: Sequence[Sequence[int]],
) -> None:
self.prompt_length = int(prompt_length)
self.token_sequences = [
[int(token) for token in sequence]
for sequence in token_sequences
if sequence
]
def __call__(self, input_ids: Any, scores: Any, **kwargs: Any) -> bool:
del scores, kwargs
generated = input_ids[0, self.prompt_length :].tolist()
return any(
len(generated) >= len(sequence)
and generated[-len(sequence) :] == sequence
for sequence in self.token_sequences
)
def build_evidence_only_retry_prompt(
question: str,
retrieved_contexts: Sequence[Mapping[str, Any]],
) -> str:
"""Build the deliberately short, deterministic second-attempt prompt."""
context = format_retrieved_contexts([dict(item) for item in retrieved_contexts])
return f"""ຄຳສັ່ງສຳລັບການຕອບຄືນໃໝ່:
- ໃຊ້ສະເພາະຫຼັກຖານທີ່ໃຫ້ໄວ້
- ຕອບເປັນພາສາລາວເທົ່ານັ້ນ
- ຕອບກົງຄຳຖາມ 1 ຫາ 3 ປະໂຫຍກສັ້ນໆ
- ຫ້າມໃຊ້ຫົວຂໍ້ ຫ້າມທວນຄຳຖາມ
- ຫ້າມໃຫ້ຕົວຢ່າງ ນອກຈາກຄຳຖາມຮ້ອງຂໍ
- ຫ້າມເພີ່ມຄຳອະທິບາຍທີ່ບໍ່ກ່ຽວຂ້ອງ
- ຫ້າມກ່າວວ່າຂໍ້ມູນບໍ່ພຽງພໍ ເມື່ອຫຼັກຖານມີຄຳຕອບ
ຫຼັກຖານ:
{context}
ຄຳຖາມ: {question}
ຄຳຕອບ:"""
def _split_answer_sentences(text: str) -> List[str]:
clean = re.sub(r"\s+", " ", str(text or "")).strip()
if not clean:
return []
chunks = re.split(r"(?<=[.!?…。])\s+|\n+", clean)
return [chunk.strip() for chunk in chunks if chunk.strip()]
def _context_answer_payload(
context: Mapping[str, Any],
question: str,
) -> str:
"""Extract an answer-bearing span without copying a stored question."""
text = str(context.get("retrieved_text") or context.get("text") or "").strip()
if not text:
return ""
for marker in ("ຄຳຕອບ:", "ຄໍາຕອບ:", "ນິຍາມ:"):
if marker in text:
text = text.split(marker, 1)[1]
break
text = re.split(r"\n\s*(?:ຄຳຖາມ|ຄໍາຖາມ)\s*:", text, maxsplit=1)[0]
text = re.sub(r"\s+", " ", text).strip(" -•\t\r\n")
if not text:
return ""
if str(context.get("source_type") or "") == "textbook":
question_tokens = set(significant_tokens(question))
sentences = _split_answer_sentences(text)
if sentences:
ranked = sorted(
enumerate(sentences),
key=lambda pair: (
len(
question_tokens
& set(significant_tokens(pair[1]))
),
-pair[0],
),
reverse=True,
)
best_index = ranked[0][0]
chosen = sentences[best_index : best_index + 2]
text = " ".join(chosen).strip()
return text
def _formula_from_payload(text: str) -> str:
matches = re.findall(
r"(?:1\s*/\s*R|[IUR])\s*=\s*[^.;\n]+",
str(text or ""),
flags=re.IGNORECASE,
)
if matches:
return matches[-1].strip(" :")
if ":" in str(text or ""):
return str(text).rsplit(":", 1)[-1].strip()
return str(text or "").strip()
def build_extractive_evidence_answer(
question: str,
retrieved_contexts: Sequence[Mapping[str, Any]],
*,
compound: bool = False,
) -> str:
"""Delegate narrow evidence extraction to the pure repair policy."""
return _build_repaired_extractive_answer(
question, retrieved_contexts, compound=compound
)
def generate_answer(
question: str,
history: Optional[List] = None,
*,
retrieved_contexts: Optional[List[Dict[str, Any]]] = None,
validation_trace: Optional[Dict[str, Any]] = None,
allow_extractive_fallback: bool = False,
evidence_confidence: Optional[Mapping[str, Any]] = None,
compound: bool = False,
) -> str:
"""Generate from evidence, use a short retry, then a gated extraction."""
if validation_trace is not None:
validation_trace.clear()
validation_trace["attempts"] = []
contexts = list(retrieved_contexts or [])
evidence_text = "\n".join(
str(item.get("retrieved_text") or item.get("text") or "")
for item in contexts
)
def generate_once(
*, attempt_type: str
) -> Tuple[str, int, bool, str, List[str]]:
evidence_only_retry = attempt_type == "evidence_only_retry"
prompt = (
build_evidence_only_retry_prompt(question, contexts)
if evidence_only_retry
else build_prompt(
question,
history,
retrieved_contexts=contexts,
)
)
inputs = tokenizer(prompt, return_tensors="pt").to(device)
token_budget = 96 if evidence_only_retry else MAX_GENERATED_TOKENS
generation_kwargs: Dict[str, Any] = {
"max_new_tokens": token_budget,
"do_sample": False if evidence_only_retry else GENERATION_DO_SAMPLE,
}
stop_sequences = [
tokenizer.encode(marker, add_special_tokens=False)
for marker in (
"\n\nຄຳຖາມ:",
"\nຄຳຖາມ:",
" ຄຳຖາມ:",
"ຄຳຖາມ:",
)
]
generation_kwargs["stopping_criteria"] = StoppingCriteriaList(
[
_StopOnGeneratedTokenSequences(
int(inputs["input_ids"].shape[1]),
stop_sequences,
)
]
)
if evidence_only_retry:
generation_kwargs.update(
{
"repetition_penalty": 1.15,
"no_repeat_ngram_size": 4,
}
)
with torch.no_grad():
outputs = model.generate(**inputs, **generation_kwargs)
generated_ids = outputs[0][inputs["input_ids"].shape[1] :]
generated_count = int(generated_ids.shape[0])
eos_ids = getattr(tokenizer, "eos_token_id", None)
if eos_ids is None:
eos_values: set = set()
elif isinstance(eos_ids, (list, tuple, set)):
eos_values = {int(value) for value in eos_ids}
else:
eos_values = {int(eos_ids)}
last_token = int(generated_ids[-1]) if generated_count else None
limit_reached = bool(
generated_count >= token_budget
and (last_token is None or last_token not in eos_values)
)
decoded = tokenizer.decode(
generated_ids, skip_special_tokens=True
).strip()
prepared, safety_transformations = prepare_generated_answer_text(decoded)
return (
prepared,
generated_count,
limit_reached,
decoded,
safety_transformations,
)
for attempt_number, attempt_type in enumerate(
("normal", "evidence_only_retry"), start=1
):
(
answer,
generated_count,
limit_reached,
raw_generated_text,
safety_transformations,
) = generate_once(attempt_type=attempt_type)
validation = validate_generated_answer(
question,
answer,
evidence_text=evidence_text,
token_limit_reached=limit_reached,
)
if validation.get("valid") and evidence_text and answer:
semantic_vectors = embed_model.encode(
[answer, question],
convert_to_numpy=True,
normalize_embeddings=True,
show_progress_bar=False,
)
semantic_similarity = float(
np.dot(semantic_vectors[0], semantic_vectors[1])
)
validation["semantic_question_similarity"] = semantic_similarity
validation[
"minimum_semantic_question_similarity"
] = OUTPUT_MIN_QUESTION_SEMANTIC_SIMILARITY
if (
answer != SAFE_LAO_REFUSAL
and semantic_similarity
< OUTPUT_MIN_QUESTION_SEMANTIC_SIMILARITY
):
validation["valid"] = False
validation.setdefault("reasons", []).append(
"low_semantic_relevance_to_question"
)
attempt_record = {
"attempt": attempt_number,
"attempt_type": attempt_type,
"generated_token_count": generated_count,
"token_budget": 96 if attempt_type == "evidence_only_retry" else MAX_GENERATED_TOKENS,
"repair_attempt": attempt_type == "evidence_only_retry",
"generated_text": answer,
"raw_generated_text": raw_generated_text,
"safety_transformations": safety_transformations,
**validation,
}
if validation_trace is not None:
validation_trace["attempts"].append(attempt_record)
if validation.get("valid"):
if validation_trace is not None:
validation_trace.update(
{
"retry_count": attempt_number - 1,
"final_action": "accepted_generated_answer",
"final_attempt_type": attempt_type,
"returned_safe_refusal": False,
"used_extractive_fallback": False,
}
)
return answer
fallback_allowed, fallback_reason = strong_evidence_for_extractive_fallback(
contexts,
evidence_confidence,
compound=compound,
)
if allow_extractive_fallback and fallback_allowed:
extractive_answer = build_extractive_evidence_answer(
question,
contexts,
compound=compound,
)
extractive_validation = validate_generated_answer(
question,
extractive_answer,
evidence_text=evidence_text,
token_limit_reached=False,
)
if extractive_validation.get("valid") and extractive_answer:
if validation_trace is not None:
validation_trace.update(
{
"retry_count": 1,
"final_action": "accepted_extractive_fallback",
"final_attempt_type": "extractive_fallback",
"returned_safe_refusal": False,
"used_extractive_fallback": True,
"fallback_reason": fallback_reason,
"extractive_fallback": {
"attempt_type": "extractive_fallback",
"generated_text": extractive_answer,
**extractive_validation,
},
}
)
return extractive_answer
fallback_reason = "extractive_fallback_failed_validation"
if validation_trace is not None:
validation_trace["extractive_fallback"] = {
"attempt_type": "extractive_fallback",
"generated_text": extractive_answer,
**extractive_validation,
}
consensus = build_consensus_structure_fallback(
question, contexts, evidence_confidence
)
if allow_extractive_fallback and consensus.get("eligible"):
consensus_answer = str(consensus.get("answer") or "")
consensus_validation = validate_generated_answer(
question,
consensus_answer,
evidence_text=evidence_text,
token_limit_reached=False,
)
if consensus_answer and consensus_validation.get("valid"):
if validation_trace is not None:
validation_trace.update(
{
"retry_count": 1,
"final_action": "accepted_consensus_structure_fallback",
"final_attempt_type": "consensus_structure_fallback",
"returned_safe_refusal": False,
"used_extractive_fallback": True,
"used_consensus_structure_fallback": True,
"fallback_reason": "consensus_supported_structure_evidence",
"standard_extractive_fallback_reason": fallback_reason,
"consensus_structure_fallback": {
**consensus,
**consensus_validation,
},
}
)
return consensus_answer
if validation_trace is not None:
validation_trace.update(
{
"retry_count": 1,
"final_action": "safe_refusal_after_failed_validation",
"final_attempt_type": "safe_refusal",
"returned_safe_refusal": True,
"used_extractive_fallback": False,
"used_consensus_structure_fallback": False,
"fallback_reason": fallback_reason,
"consensus_structure_fallback": consensus,
}
)
return SAFE_LAO_REFUSAL
# -----------------------------
# QA lookup (exact + fuzzy + partial + embeddings)
# -----------------------------
def _ensure_qa_embeddings() -> None:
"""
Build (or rebuild) embeddings for ALL_QA_KNOWLEDGE questions.
Cached on qa_store to avoid recomputing every chat turn.
"""
while True:
knowledge = getattr(qa_store, "ALL_QA_KNOWLEDGE", None) or []
corpus_version = getattr(qa_store, "QA_CORPUS_VERSION", 0)
n = len(knowledge)
if not knowledge:
qa_store.QA_Q_EMBEDDINGS = None
qa_store.QA_Q_EMBED_N = 0
qa_store.QA_Q_EMBED_VERSION = corpus_version
return
if (
getattr(qa_store, "QA_Q_EMBEDDINGS", None) is not None
and getattr(qa_store, "QA_Q_EMBED_N", 0) == n
and getattr(qa_store, "QA_Q_EMBED_VERSION", -1) == corpus_version
):
return
texts = [(item.get("norm_q", "") or "") for item in knowledge]
embeddings = embed_model.encode(
texts,
convert_to_numpy=True,
normalize_embeddings=True,
show_progress_bar=False,
)
# A reload may complete while encoding is running. Never publish vectors
# unless they still match the current corpus generation and list.
if (
corpus_version != getattr(qa_store, "QA_CORPUS_VERSION", 0)
or knowledge is not getattr(qa_store, "ALL_QA_KNOWLEDGE", None)
):
continue
qa_store.QA_Q_EMBEDDINGS = embeddings
qa_store.QA_Q_EMBED_N = n
qa_store.QA_Q_EMBED_VERSION = corpus_version
return
def _qa_embedding_snapshot():
"""Return a corpus/embedding pair from the same Q&A generation."""
while True:
_ensure_qa_embeddings()
corpus_version = getattr(qa_store, "QA_CORPUS_VERSION", 0)
knowledge = list(getattr(qa_store, "ALL_QA_KNOWLEDGE", None) or [])
embeddings = getattr(qa_store, "QA_Q_EMBEDDINGS", None)
if (
corpus_version == getattr(qa_store, "QA_CORPUS_VERSION", 0)
and getattr(qa_store, "QA_Q_EMBED_VERSION", -1) == corpus_version
and (embeddings is None or len(embeddings) == len(knowledge))
):
return knowledge, embeddings
def _escape_md_cell(text: str) -> str:
return (text or "").replace("|", "\\|").replace("\n", "
")
def _build_markdown_table(headers: List[str], rows: List[List[str]]) -> str:
header_line = "| " + " | ".join(_escape_md_cell(h) for h in headers) + " |"
sep_line = "| " + " | ".join("---" for _ in headers) + " |"
row_lines = [
"| " + " | ".join(_escape_md_cell(cell) for cell in row) + " |"
for row in rows
]
return "\n".join([header_line, sep_line, *row_lines])
def _escape_answer_text_html(text: str) -> str:
return html.escape(text or "").replace("\n", "
")
def _format_answer_text_html(text: str) -> str:
safe_text = _escape_answer_text_html(text)
safe_text = re.sub(r"\*\*(.+?)\*\*", r"\1", safe_text)
return safe_text
def _render_plain_text_block(text: str) -> str:
return (
'