Spaces:
Running
Running
Commit ·
cb7868a
1
Parent(s): f50c177
refactor: clean legacy, sync deps, add embedding cache, split RAGService, split search_service, add tests
Browse files1. Clean legacy: removed legacy/, scratch/, test_clean/, __pycache__/,
artifacts/, root-level junk (gitignored)
2. Sync requirements: deleted stale root requirements.txt, added pytest
3. Embedding LRU cache: OrderedDict 256 entries in RAGService._get_embedding
4. RAGService refactor: __init__ -> _init_qdrant, _init_qdrant_local, _init_qdrant_server
5. Split search_service.py: extracted pali_utils.py (516->249 lines)
6. Test infra: 3 test files (260 lines) with pytest
- .gitignore +2 -2
- webapp/tipitaka-api/app/services/pali_utils.py +281 -0
- webapp/tipitaka-api/app/services/rag_service.py +113 -80
- webapp/tipitaka-api/app/services/search_service.py +10 -277
- webapp/tipitaka-api/requirements.txt +2 -0
- webapp/tipitaka-api/tests/__init__.py +3 -0
- webapp/tipitaka-api/tests/conftest.py +23 -0
- webapp/tipitaka-api/tests/test_pali_utils.py +106 -0
- webapp/tipitaka-api/tests/test_rag_service.py +59 -0
- webapp/tipitaka-api/tests/test_search_service.py +92 -0
.gitignore
CHANGED
|
@@ -49,8 +49,8 @@ _inspect_nb.py
|
|
| 49 |
_test_extract.py
|
| 50 |
_test_pdf.py
|
| 51 |
|
| 52 |
-
# Test scripts
|
| 53 |
-
test_*.py
|
| 54 |
|
| 55 |
# Backup files
|
| 56 |
*.bak.py
|
|
|
|
| 49 |
_test_extract.py
|
| 50 |
_test_pdf.py
|
| 51 |
|
| 52 |
+
# Test scripts (root-level only)
|
| 53 |
+
/test_*.py
|
| 54 |
|
| 55 |
# Backup files
|
| 56 |
*.bak.py
|
webapp/tipitaka-api/app/services/pali_utils.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pali/Thai text utilities for the Tipitaka RAG system.
|
| 3 |
+
|
| 4 |
+
Contains character confusion maps, spelling correctors, and curated
|
| 5 |
+
vocabulary lists used by the search pipeline.
|
| 6 |
+
|
| 7 |
+
All module-level constants are computed once at import time (zero runtime cost).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from difflib import SequenceMatcher
|
| 11 |
+
from typing import List
|
| 12 |
+
|
| 13 |
+
# ── Optional: PyThaiNLP for general Thai spell-checking ───────────
|
| 14 |
+
try:
|
| 15 |
+
from pythainlp.spell import correct as pythai_spell_correct
|
| 16 |
+
from pythainlp.tokenize import word_tokenize as pythai_tokenize
|
| 17 |
+
HAS_PYTHAINLP = True
|
| 18 |
+
except ImportError:
|
| 19 |
+
HAS_PYTHAINLP = False
|
| 20 |
+
|
| 21 |
+
# ── Thai Pali Character Confusion Map ─────────────────────────────
|
| 22 |
+
# These are character-level substitution errors commonly made when
|
| 23 |
+
# typing Pali terms in Thai script (คนไทยมักพิมพ์ส/ศ/ษ สลับกัน ฯลฯ)
|
| 24 |
+
THAI_PALI_CONFUSIONS = {
|
| 25 |
+
# ส group — all map to /s/ sound
|
| 26 |
+
"ส": ["ศ", "ษ"],
|
| 27 |
+
"ศ": ["ส", "ษ"],
|
| 28 |
+
"ษ": ["ส", "ศ"],
|
| 29 |
+
# ฏ group — retroflex / dental stops
|
| 30 |
+
"ฏ": ["ต", "ฐ"],
|
| 31 |
+
"ฐ": ["ต", "ฏ", "ถ"],
|
| 32 |
+
"ถ": ["ต", "ฐ", "ฑ"],
|
| 33 |
+
"ฑ": ["ต", "ฐ", "ถ", "ฒ"],
|
| 34 |
+
"ฒ": ["ด", "ฑ", "ท"],
|
| 35 |
+
# ณ / น
|
| 36 |
+
"ณ": ["น"],
|
| 37 |
+
"น": ["ณ"],
|
| 38 |
+
# ภ / พ
|
| 39 |
+
"ภ": ["พ"],
|
| 40 |
+
"พ": ["ภ"],
|
| 41 |
+
# ฬ / ล
|
| 42 |
+
"ฬ": ["ล"],
|
| 43 |
+
"ล": ["ฬ"],
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _generate_pali_variants(word: str) -> set[str]:
|
| 48 |
+
"""Generate common misspellings for a Pali term via single-character substitutions.
|
| 49 |
+
|
| 50 |
+
For each character in `word`, if it belongs to a confusion group, replace it
|
| 51 |
+
with each alternative to produce a likely misspelling. Only single-character
|
| 52 |
+
changes are generated (not combinatorial) to keep the map lean.
|
| 53 |
+
"""
|
| 54 |
+
variants: set[str] = set()
|
| 55 |
+
for i, ch in enumerate(word):
|
| 56 |
+
if ch in THAI_PALI_CONFUSIONS:
|
| 57 |
+
for alt in THAI_PALI_CONFUSIONS[ch]:
|
| 58 |
+
variant = word[:i] + alt + word[i + 1:]
|
| 59 |
+
if variant != word:
|
| 60 |
+
variants.add(variant)
|
| 61 |
+
return variants
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _pythai_autocorrect(q: str) -> str:
|
| 65 |
+
"""Use PyThaiNLP to correct general Thai spelling errors (not Pali-specific).
|
| 66 |
+
|
| 67 |
+
Falls back to the original query if PyThaiNLP is unavailable or raises.
|
| 68 |
+
Only applies corrections when at least one token changes.
|
| 69 |
+
"""
|
| 70 |
+
if not HAS_PYTHAINLP or not q:
|
| 71 |
+
return q
|
| 72 |
+
try:
|
| 73 |
+
# Tokenize then spell-correct each token independently
|
| 74 |
+
tokens = pythai_tokenize(q)
|
| 75 |
+
corrected_tokens = []
|
| 76 |
+
changed = False
|
| 77 |
+
for tok in tokens:
|
| 78 |
+
if len(tok) <= 1: # Skip single-char tokens (spaces, punctuation)
|
| 79 |
+
corrected_tokens.append(tok)
|
| 80 |
+
continue
|
| 81 |
+
suggestions = pythai_spell_correct(tok)
|
| 82 |
+
if suggestions and suggestions[0] != tok:
|
| 83 |
+
corrected_tokens.append(suggestions[0])
|
| 84 |
+
changed = True
|
| 85 |
+
else:
|
| 86 |
+
corrected_tokens.append(tok)
|
| 87 |
+
if changed:
|
| 88 |
+
return "".join(corrected_tokens)
|
| 89 |
+
except Exception:
|
| 90 |
+
pass
|
| 91 |
+
return q
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _similar_enough(original: str, corrected: str) -> bool:
|
| 95 |
+
"""Check if PyThaiNLP correction is reasonably similar to the original.
|
| 96 |
+
|
| 97 |
+
PyThaiNLP uses a general Thai dictionary and does NOT understand Pali
|
| 98 |
+
specialized vocabulary. It can aggressively "correct" Pali technical terms
|
| 99 |
+
into unrelated Thai words.
|
| 100 |
+
|
| 101 |
+
Examples of destructive corrections we reject:
|
| 102 |
+
ปุพเพนิวาสานุสสติญาณ (20 chars) → ปพเนวสสตญ (9 chars) — dropped 55% of text
|
| 103 |
+
สติปัฏฐาน (9 chars) → correct variant (still ~9 chars) — accepted
|
| 104 |
+
|
| 105 |
+
We use TWO independent guards:
|
| 106 |
+
1. Length ratio: corrected must be ≥45% of original length (rejects dropped chars)
|
| 107 |
+
2. SequenceMatcher ratio: ≥0.65 character overlap (rejects garbled text)
|
| 108 |
+
|
| 109 |
+
Both must pass for the correction to be accepted.
|
| 110 |
+
"""
|
| 111 |
+
if original == corrected or not original or not corrected:
|
| 112 |
+
return True
|
| 113 |
+
# Guard 1: Length ratio — if corrected is drastically shorter, it's a lossy "correction"
|
| 114 |
+
len_ratio = min(len(original), len(corrected)) / max(len(original), len(corrected))
|
| 115 |
+
if len_ratio < 0.45:
|
| 116 |
+
return False
|
| 117 |
+
# Guard 2: Edit-distance ratio — actual character overlap
|
| 118 |
+
# SequenceMatcher.ratio() = 2*M / (len(a) + len(b)) where M = matching chars
|
| 119 |
+
if SequenceMatcher(None, original, corrected, autojunk=False).ratio() < 0.65:
|
| 120 |
+
return False
|
| 121 |
+
return True
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ── Pali/Dhamma Auto-correction (full list from v2.1) ─────────────
|
| 125 |
+
PALI_CORRECTIONS: dict[str, str] = {
|
| 126 |
+
# อริยสัจ
|
| 127 |
+
"อริยะสัจ": "อริยสัจ", "อริยะสัจ4": "อริยสัจ", "อริยสัจ4": "อริยสัจ ๔",
|
| 128 |
+
"สี่อริยสัจ": "อริยสัจ", "4อริยสัจ": "อริยสัจ",
|
| 129 |
+
# นิพพาน
|
| 130 |
+
"นิพพาณ": "นิพพาน", "นิพาน": "นิพพาน", "นิรพาน": "นิพพาน", "นิบาน": "นิพพาน",
|
| 131 |
+
# กรรม / ธรรม
|
| 132 |
+
"กรม": "กรรม", "ธรม": "ธรรม", "ธรรมะ": "ธรรม",
|
| 133 |
+
# สังสารวัฏ
|
| 134 |
+
"สังสาวัฎ": "สังสารวัฏ", "วัฎสังสาร": "สังสารวัฏ", "สังสารวัฎ": "สังสารวัฏ",
|
| 135 |
+
# ไตรลักษณ์
|
| 136 |
+
"ไตรลักณ์": "ไตรลักษณ์", "ไตรลักษน์": "ไตรลักษณ์", "ไตรลัษณ์": "ไตรลักษณ์",
|
| 137 |
+
"3ลักษณะ": "ไตรลักษณ์", "สามลักษณะ": "ไตรลักษณ์",
|
| 138 |
+
# อนิจจัง / ทุกขัง / อนัตตา
|
| 139 |
+
"อนิตย์": "อนิจจัง", "อนิตยา": "อนิจจัง",
|
| 140 |
+
"ทุกขะ": "ทุกข์",
|
| 141 |
+
"อนัตตะ": "อนัตตา", "อนาตา": "อนัตตา",
|
| 142 |
+
# สติปัฏฐาน
|
| 143 |
+
"สติปฏฐาน": "สติปัฏฐาน", "สติปัฐฐาน": "สติปัฏฐาน", "สติปัฏฐาน4": "สติปัฏฐาน ๔",
|
| 144 |
+
# มรรค
|
| 145 |
+
"มัก": "มรรค", "มาก": "มรรค",
|
| 146 |
+
"อัฎฐมรรค": "อัฏฐมรรค", "มรรค8": "อัฏฐมรรค",
|
| 147 |
+
"8มรรค": "อัฏฐมรรค", "มรรคแปด": "อัฏฐมรรค",
|
| 148 |
+
# โพชฌงค์
|
| 149 |
+
"โพชงค์": "โพชฌงค์", "โพชฌงค7": "โพชฌงค์",
|
| 150 |
+
# ปฏิจจสมุปบาท
|
| 151 |
+
"ปฏิจสมุปบาท": "ปฏิจจสมุปบาท", "ปฎิจจสมุปบาท": "ปฏิจจสมุปบาท",
|
| 152 |
+
"ปัจจยาการ": "ปฏิจจสมุปบาท",
|
| 153 |
+
# กิเลส / ขันธ์
|
| 154 |
+
"กิเลษ": "กิเลส", "กิเล็ส": "กิเลส",
|
| 155 |
+
"ขัน5": "ขันธ์ ๕", "5ขันธ์": "ขันธ์ ๕", "ขันธ์5": "ขันธ์ ๕", "ขันห์": "ขันธ์",
|
| 156 |
+
# โพธิ / วินัย / อภิธรรม
|
| 157 |
+
"โพธิ์": "โพธิ", "โพธิ์สัตว์": "โพธิสัตว์", "พระโพธิ์สัตว์": "โพธิสัตว์",
|
| 158 |
+
"วินัยปิฏก": "วินัยปิฎก", "วินัยปิฎฎก": "วินัยปิฎก",
|
| 159 |
+
"อภิธรม": "อภิธรรม", "อภิธัมม์": "อภิธรรม",
|
| 160 |
+
# สุตตันตปิฎก
|
| 161 |
+
"สุตันตปิฎก": "สุตตันตปิฎก", "สูตรปิฎก": "สุตตันตปิฎก",
|
| 162 |
+
# สมาธิ / วิปัสสนา / สมถะ
|
| 163 |
+
"สมาธิ์": "สมาธิ",
|
| 164 |
+
"วิปัสนา": "วิปัสสนา", "วิปาสสนา": "วิปัสสนา", "วิปสสนา": "วิปัสสนา",
|
| 165 |
+
"สมถ": "สมถะ", "สมธะ": "สมถะ",
|
| 166 |
+
# ปรินิพพาน
|
| 167 |
+
"ปรินิพาน": "ปรินิพพาน", "ปรินิพพาณ": "ปรินิพพาน",
|
| 168 |
+
# มรณสติ
|
| 169 |
+
"มรนสติ": "มรณสติ",
|
| 170 |
+
# มหาบุรุษ
|
| 171 |
+
"มหาบุรษ": "มหาบุรุษ",
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def to_thai_digits(s: str) -> str:
|
| 176 |
+
return s.translate(str.maketrans("0123456789", "๐๑๒๓๔๕๖๗๘๙"))
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ── Curated popular terms (fallback when search_log is empty) ──────
|
| 180 |
+
# Source: https://84000.org/tipitaka/dic/ (topic headings, stripped of varga numbers)
|
| 181 |
+
POPULAR_TERMS: List[str] = [
|
| 182 |
+
# ── Core doctrines ──────────────────────────────────────────
|
| 183 |
+
"อริยสัจ", "นิพพาน", "ไตรลักษณ์", "อนิจจัง", "ทุกข์", "อนัตตา",
|
| 184 |
+
"สติปัฏฐาน", "อัฏฐมรรค", "โพชฌงค์", "ปฏิจจสมุปบาท",
|
| 185 |
+
"สมาธิ", "วิปัสสนา", "สมถะ", "ศีล", "ปัญญา",
|
| 186 |
+
"กรรม", "วิบาก", "กิเลส", "ตัณหา", "อวิชชา",
|
| 187 |
+
"โพธิสัตว์", "ปรินิพพาน", "สังสารวัฏ", "มรณสติ",
|
| 188 |
+
"เมตตา", "กรุณา", "มุทิตา", "อุเบกขา",
|
| 189 |
+
"อภิธรรม", "วินัยปิฎ��", "สุตตันตปิฎก",
|
| 190 |
+
"ขันธ์", "อายตนะ", "ธาตุ", "อินทรีย์",
|
| 191 |
+
"โพธิปักขิยธรรม", "สัมมาสมาธิ", "สัมมาสติ",
|
| 192 |
+
"บารมี", "เจตสิก", "จิต", "รูป", "นาม",
|
| 193 |
+
# ── From 84000 dictionary ───────────────────────────────────
|
| 194 |
+
"กถาวัตถุ", "กรรมกิเลส", "กรรมฐาน", "กสิณ",
|
| 195 |
+
"กัลยาณมิตตตา", "กัลยาณมิตรธรรม", "กาม", "กามคุณ",
|
| 196 |
+
"กามโภคี", "กาลามสูตรกังขานิยฐาน", "กิจ", "กิจในอริยสัจจ์",
|
| 197 |
+
"กุลจิรัฏฐิติธรรม", "กุศลกรรมบถ", "กุศลมูล", "กุศลวิตก",
|
| 198 |
+
"คารวะ", "ฆราวาสธรรม", "จรณะ", "จริต", "จักขุ",
|
| 199 |
+
"จักร", "จักรวรรดิวัตร", "ฌาน", "ฌาน 2 ประเภท", "ญาณ",
|
| 200 |
+
"ถูปารหบุคคล", "ทวาร", "ทศพลญาณ", "ทักขิณาวิสุทธิ",
|
| 201 |
+
"ทาน", "ทิฏฐธัมมิกัตถสังวัตตนิกธรรม", "ทิฏฐิ", "ทิศ",
|
| 202 |
+
"ที่สุด", "ทุกขตา", "ทุจริต", "ธรรม", "ธรรมขันธ์",
|
| 203 |
+
"ธรรมคุณ", "ธรรมคุ้มครองโลก", "ธรรมทำให้งาม", "ธรรมนิยาม",
|
| 204 |
+
"ธรรมมีอุปการะมาก", "ธรรมสมาทาน", "ธรรมสมาธิ",
|
| 205 |
+
"ธรรมสวนานิสงส์", "ธรรมเทสกธรรม", "ธาตุกัมมัฏฐาน",
|
| 206 |
+
"ธุดงค์", "ธุระ", "นวกภิกขุธรรม", "นวังคสัตถุสาสน์",
|
| 207 |
+
"นาถกรณธรรม", "นิมิต", "นิยาม", "นิวรณ์", "นิโรธ",
|
| 208 |
+
"บริษัท", "บัญญัติ 2", "บุคคล", "บุคคลหาได้ยาก",
|
| 209 |
+
"บุญกิริยาวัตถุ", "บุตร", "บุพนิมิตแห่งมรรค", "บูชา",
|
| 210 |
+
"ปฏิปทา", "ปฏิสันถาร", "ปฏิสัมภิทา", "ปธาน",
|
| 211 |
+
"ปปัญจะ, ปปัญจธรรม", "ปรมัตถธรรม", "ประมาณ", "ปริญญา",
|
| 212 |
+
"ปริเยสนา", "ปัจจัย", "ปัจจัยให้เกิดสัมมาทิฏฐิ",
|
| 213 |
+
"ปัพพชิตอภิณหปัจจเวกขณ์", "ปาฏิหาริย์", "ปาปณิกธรรม",
|
| 214 |
+
"ปาพจน์", "ปาริสุทธิศีล", "ปิยรูป สาตรูป", "ปีติ",
|
| 215 |
+
"ผล", "พร", "พรหมวิหาร", "พละ",
|
| 216 |
+
"พละ 5 ของพระมหากษัตริย์", "พหูสูตมีองค์", "พุทธคุณ",
|
| 217 |
+
"พุทธจริยา", "พุทธโอวาท", "ภพ", "ภรรยา",
|
| 218 |
+
"ภัพพตาธรรม", "ภาวนา", "ภูมิ", "มงคล", "มรรค",
|
| 219 |
+
"มรรคมีองค์", "มละ", "มหาปเทส",
|
| 220 |
+
"มหาปเทส 4 เฉพาะในทางพระวินัย", "มหาภูต", "มัจฉริยะ",
|
| 221 |
+
"มานะ", "มาร", "มิจฉัตตะ", "มิตรปฏิรูปก์",
|
| 222 |
+
"รัตนตรัย", "ราชธรรม", "ฤทธิ์", "ลักษณะตัดสินธรรมวินัย",
|
| 223 |
+
"ลัทธินอกพระพุทธศาสนา", "ลีลาการสอน", "วณิชชา", "วรรณะ",
|
| 224 |
+
"วัฏฏะ", "วัฒนมุข", "วัตถุประสงค์ในการบัญญัติวินัย",
|
| 225 |
+
"วิชชา", "วิญญาณ", "วิญญาณฐิติ",
|
| 226 |
+
"วิธีปฏิบัติต่อทุกข์-สุข", "วิบัติ", "วิปัลลาส",
|
| 227 |
+
"วิปัสสนาญาณ", "วิปัสสนูปกิเลส", "วิมุตติ", "วิรัติ",
|
| 228 |
+
"วิสุทธิ", "วิเวก", "วิโมกข์", "วุฒิ", "ศรัทธา",
|
| 229 |
+
"ศีล 8 ทั้งอาชีวะ", "สกทาคามี", "สมชีวิธรรม", "สมบัติ",
|
| 230 |
+
"สมาธิภาวนา", "สมาบัติ", "สรณะ", "สวรรค์",
|
| 231 |
+
"สังขตลักษณะ", "สังขาร", "สังคหวัตถุ",
|
| 232 |
+
"สังคห���ัตถุของผู้ครองแผ่นดิน", "สังคหะ", "สังฆคุณ",
|
| 233 |
+
"สังวร", "สังเวชนียสถาน", "สังโยชน์", "สัจจะ", "สัญญา",
|
| 234 |
+
"สัตตาวาส", "สัทธรรม", "สันโดษ", "สัปปายะ",
|
| 235 |
+
"สัปปุริสทาน", "สัปปุริสธรรม", "สัปปุริสบัญญัติ",
|
| 236 |
+
"สัมปชัญญะ", "สัมปทา", "สัมปรายิกัตถสังวัตตนิกธรรม",
|
| 237 |
+
"สัมผัส", "สัมมัตตะ", "สารณียธรรม", "สาสน์", "สิกขา",
|
| 238 |
+
"สุข", "สุขของคฤหัสถ์", "สุจริต", "สุทธาวาส", "สุทธิ",
|
| 239 |
+
"สุหทมิตร", "อกุศลกรรมบถ", "อกุศลมูล", "อกุศลวิตก",
|
| 240 |
+
"อคติ", "อธิปไตย", "อธิษฐาน", "อนันตริยกรรม",
|
| 241 |
+
"อนาคามี", "อนุตตริยะ", "อนุบุพพวิหาร", "อนุปุพพิกถา",
|
| 242 |
+
"อนุสติ", "อนุสัย", "อบาย", "อบายมุข",
|
| 243 |
+
"อปริหานิยธรรม", "อปัณณกปฏิปทา", "อปัสเสนะ",
|
| 244 |
+
"อภิญญา", "อภิฐาน", "อภิณหปัจจเวกขณ์", "อภิสังขาร",
|
| 245 |
+
"อรหันต์", "อริยทรัพย์", "อริยบุคคล", "อริยวงศ์",
|
| 246 |
+
"อริยวัฑฒิ", "อริยสัจจ์", "อรูป", "อสังขตลักษณะ",
|
| 247 |
+
"อสุภะ", "อัคคิ", "อัตถะ", "อันตคาหิกทิฏฐิ",
|
| 248 |
+
"อันตรายของภิกษุสามเณรผู้บวชใหม่", "อัปปมาทะ",
|
| 249 |
+
"อาการที่พระพุทธเจ้าทรงสั่งสอน", "อาจารย์",
|
| 250 |
+
"อานาปานสติ 16 ฐาน", "อายตนะภายนอก", "อายตนะภายใน",
|
| 251 |
+
"อายุสสธรรม", "อาวาสิกธรรม", "อาสวะ", "อาหาร",
|
| 252 |
+
"อิทธิบาท", "อุบาสกธรรม", "อุปกิเลส", "อุปัญญาตธรรม",
|
| 253 |
+
"อุปาทาน", "อุปาทารูป", "เจดีย์", "เจตนา", "เถรธรรม",
|
| 254 |
+
"เทพ", "เทวทูต", "เทศนา", "เบญจธรรม", "เมถุนสังโยค",
|
| 255 |
+
"เวทนา", "เวปุลลธรรม", "เวปุลละ", "เวสารัชชกรณธรรม",
|
| 256 |
+
"เวสารัชชะ", "โกศล", "โภควิภาค", "โภคอาทิยะ",
|
| 257 |
+
"โยคะ", "โยนิ", "โยนิโสมนสิการ", "โลก", "โลกธรรม",
|
| 258 |
+
"โลกุตตรธรรม", "โสดาบัน", "โสตาปัตติยังคะ", "โอฆะ",
|
| 259 |
+
"ไตรปิฎก",
|
| 260 |
+
]
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _build_pali_corrections() -> dict[str, str]:
|
| 264 |
+
"""Extend PALI_CORRECTIONS with auto-generated variants from all POPULAR_TERMS.
|
| 265 |
+
|
| 266 |
+
For every term in POPULAR_TERMS, this generates likely misspellings
|
| 267 |
+
using character confusion groups (ส↔ศ↔ษ, ฏ↔ต↔ฐ, ณ↔น, ภ↔พ, ฬ↔ล, etc.)
|
| 268 |
+
and adds them to the corrections dict.
|
| 269 |
+
|
| 270 |
+
This is called once at module load time so there is zero runtime cost.
|
| 271 |
+
"""
|
| 272 |
+
corrections = dict(PALI_CORRECTIONS)
|
| 273 |
+
for term in POPULAR_TERMS:
|
| 274 |
+
for variant in _generate_pali_variants(term):
|
| 275 |
+
if variant not in corrections:
|
| 276 |
+
corrections[variant] = term
|
| 277 |
+
return corrections
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
# Build the full corrections dict at load time (hand-curated + auto-generated)
|
| 281 |
+
PALI_CORRECTIONS_FULL = _build_pali_corrections()
|
webapp/tipitaka-api/app/services/rag_service.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import logging
|
| 2 |
from pathlib import Path
|
|
|
|
| 3 |
import qdrant_client
|
| 4 |
from qdrant_client.http import models as qmodels
|
| 5 |
import httpx
|
|
@@ -48,91 +49,111 @@ class RAGService:
|
|
| 48 |
self.model = None # Flag: None = not verified, 'ready' = OK
|
| 49 |
self.reranker = None
|
| 50 |
self.actual_chunks_col = "tipitaka_chunks" # Default name
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
try:
|
| 53 |
-
# 1. Determine Mode (Auto-detect Server vs Local)
|
| 54 |
-
qdrant_url = getattr(settings, "QDRANT_URL", None)
|
| 55 |
-
|
| 56 |
-
if not qdrant_url:
|
| 57 |
-
try:
|
| 58 |
-
with httpx.Client() as client:
|
| 59 |
-
response = client.get("http://localhost:6333/healthz", timeout=1.0)
|
| 60 |
-
if response.status_code == 200:
|
| 61 |
-
qdrant_url = "http://localhost:6333"
|
| 62 |
-
logger.info(f"Auto-detected running Qdrant Server at {qdrant_url}")
|
| 63 |
-
except Exception:
|
| 64 |
-
pass
|
| 65 |
-
|
| 66 |
-
is_local = not bool(qdrant_url)
|
| 67 |
-
|
| 68 |
if is_local:
|
| 69 |
-
|
| 70 |
-
storage_path = Path(self.qdrant_path)
|
| 71 |
-
storage_path.mkdir(parents=True, exist_ok=True)
|
| 72 |
-
|
| 73 |
-
lock_file = storage_path / ".lock"
|
| 74 |
-
if lock_file.exists():
|
| 75 |
-
try:
|
| 76 |
-
logger.warning(f"Removing stale Qdrant lock file: {lock_file}")
|
| 77 |
-
lock_file.unlink()
|
| 78 |
-
except Exception as e:
|
| 79 |
-
logger.error(f"Failed to remove lock file: {e}")
|
| 80 |
-
|
| 81 |
-
for col_name in self.collections:
|
| 82 |
-
col_dir = storage_path / "collections" / col_name
|
| 83 |
-
if not col_dir.exists():
|
| 84 |
-
snap_path = self.snapshot_dir / f"{col_name}.snapshot"
|
| 85 |
-
if snap_path.exists():
|
| 86 |
-
logger.info(f"Restoring '{col_name}' via manual extraction...")
|
| 87 |
-
self._extract_snapshot(col_name, snap_path)
|
| 88 |
-
|
| 89 |
-
self.client = qdrant_client.QdrantClient(path=self.qdrant_path)
|
| 90 |
-
logger.info("Qdrant Local Client initialized.")
|
| 91 |
-
|
| 92 |
else:
|
| 93 |
-
|
| 94 |
-
self.client = qdrant_client.QdrantClient(url=qdrant_url)
|
| 95 |
-
|
| 96 |
-
try:
|
| 97 |
-
all_cols = [c.name for c in self.client.get_collections().collections]
|
| 98 |
-
except Exception as e:
|
| 99 |
-
logger.error(f"Failed to list collections: {e}")
|
| 100 |
-
all_cols = []
|
| 101 |
-
|
| 102 |
-
for col_name in self.collections:
|
| 103 |
-
actual_col = None
|
| 104 |
-
if col_name in all_cols:
|
| 105 |
-
actual_col = col_name
|
| 106 |
-
else:
|
| 107 |
-
matches = [c for c in all_cols if c.startswith(f"{col_name}_") or c.startswith(col_name)]
|
| 108 |
-
if matches: actual_col = matches[0]
|
| 109 |
-
|
| 110 |
-
if actual_col:
|
| 111 |
-
if col_name == "tipitaka_chunks": self.actual_chunks_col = actual_col
|
| 112 |
-
else:
|
| 113 |
-
snap_filename = f"{col_name}.snapshot"
|
| 114 |
-
target_snap = ALLOWED_SNAP_ROOT / snap_filename
|
| 115 |
-
if not target_snap.exists(): target_snap = self.snapshot_dir / snap_filename
|
| 116 |
-
|
| 117 |
-
if target_snap.exists():
|
| 118 |
-
import os
|
| 119 |
-
logger.info(f"Restoring server collection '{col_name}' from {target_snap}...")
|
| 120 |
-
abs_snap_path = os.path.abspath(target_snap).replace("\\", "/")
|
| 121 |
-
if not abs_snap_path.startswith("/"): abs_snap_path = "/" + abs_snap_path
|
| 122 |
-
try:
|
| 123 |
-
self.client.recover_snapshot(col_name, location=f"file://{abs_snap_path}")
|
| 124 |
-
if col_name == "tipitaka_chunks": self.actual_chunks_col = col_name
|
| 125 |
-
except Exception as e:
|
| 126 |
-
logger.error(f"Failed to restore: {e}")
|
| 127 |
-
|
| 128 |
-
# 3. Pre-load Models (Embedding + Reranker)
|
| 129 |
-
self._load_model()
|
| 130 |
-
self._load_reranker()
|
| 131 |
-
|
| 132 |
except Exception as e:
|
| 133 |
logger.error(f"RAG Initialization Error: {e}")
|
| 134 |
self.client = None
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
def _load_model(self):
|
| 137 |
"""Verify Ollama embedding service is accessible."""
|
| 138 |
if self.model is None:
|
|
@@ -160,14 +181,26 @@ class RAGService:
|
|
| 160 |
self.reranker = "error"
|
| 161 |
|
| 162 |
def _get_embedding(self, text: str) -> list:
|
| 163 |
-
"""Get embedding vector from Ollama API."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
response = httpx.post(
|
| 165 |
f"{OLLAMA_URL}/api/embed",
|
| 166 |
json={"model": EMBED_MODEL, "input": text},
|
| 167 |
timeout=30
|
| 168 |
)
|
| 169 |
response.raise_for_status()
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
async def query(self, text: str, n_results: int = 10, threshold: float = 0.2) -> str:
|
| 173 |
"""
|
|
|
|
| 1 |
import logging
|
| 2 |
from pathlib import Path
|
| 3 |
+
from collections import OrderedDict
|
| 4 |
import qdrant_client
|
| 5 |
from qdrant_client.http import models as qmodels
|
| 6 |
import httpx
|
|
|
|
| 49 |
self.model = None # Flag: None = not verified, 'ready' = OK
|
| 50 |
self.reranker = None
|
| 51 |
self.actual_chunks_col = "tipitaka_chunks" # Default name
|
| 52 |
+
self._embed_cache = OrderedDict() # LRU cache: text → embedding
|
| 53 |
+
self._embed_cache_max = 256 # max cached entries
|
| 54 |
+
self.client = None
|
| 55 |
+
|
| 56 |
+
# Delegate heavy Qdrant init to a dedicated method
|
| 57 |
+
self._init_qdrant()
|
| 58 |
+
|
| 59 |
+
# Pre-load models (Embedding + Reranker) — safe to fail gracefully
|
| 60 |
+
self._load_model()
|
| 61 |
+
self._load_reranker()
|
| 62 |
+
|
| 63 |
+
def _init_qdrant(self):
|
| 64 |
+
"""Heavy initialization: detect mode, extract snapshots, create Qdrant client."""
|
| 65 |
+
settings = get_settings()
|
| 66 |
+
qdrant_url = getattr(settings, "QDRANT_URL", None)
|
| 67 |
+
|
| 68 |
+
# Auto-detect Qdrant server if not configured
|
| 69 |
+
if not qdrant_url:
|
| 70 |
+
try:
|
| 71 |
+
with httpx.Client() as client:
|
| 72 |
+
response = client.get("http://localhost:6333/healthz", timeout=1.0)
|
| 73 |
+
if response.status_code == 200:
|
| 74 |
+
qdrant_url = "http://localhost:6333"
|
| 75 |
+
logger.info(f"Auto-detected running Qdrant Server at {qdrant_url}")
|
| 76 |
+
except Exception:
|
| 77 |
+
pass
|
| 78 |
+
|
| 79 |
+
is_local = not bool(qdrant_url)
|
| 80 |
+
|
| 81 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
if is_local:
|
| 83 |
+
self._init_qdrant_local()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
else:
|
| 85 |
+
self._init_qdrant_server(qdrant_url)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
except Exception as e:
|
| 87 |
logger.error(f"RAG Initialization Error: {e}")
|
| 88 |
self.client = None
|
| 89 |
|
| 90 |
+
def _init_qdrant_local(self):
|
| 91 |
+
"""Initialize Qdrant in Local Mode — extract snapshots if needed."""
|
| 92 |
+
logger.info(f"Initializing Qdrant in Local Mode at {self.qdrant_path}")
|
| 93 |
+
storage_path = Path(self.qdrant_path)
|
| 94 |
+
storage_path.mkdir(parents=True, exist_ok=True)
|
| 95 |
+
|
| 96 |
+
lock_file = storage_path / ".lock"
|
| 97 |
+
if lock_file.exists():
|
| 98 |
+
try:
|
| 99 |
+
logger.warning(f"Removing stale Qdrant lock file: {lock_file}")
|
| 100 |
+
lock_file.unlink()
|
| 101 |
+
except Exception as e:
|
| 102 |
+
logger.error(f"Failed to remove lock file: {e}")
|
| 103 |
+
|
| 104 |
+
for col_name in self.collections:
|
| 105 |
+
col_dir = storage_path / "collections" / col_name
|
| 106 |
+
if not col_dir.exists():
|
| 107 |
+
snap_path = self.snapshot_dir / f"{col_name}.snapshot"
|
| 108 |
+
if snap_path.exists():
|
| 109 |
+
logger.info(f"Restoring '{col_name}' via manual extraction...")
|
| 110 |
+
self._extract_snapshot(col_name, snap_path)
|
| 111 |
+
|
| 112 |
+
self.client = qdrant_client.QdrantClient(path=self.qdrant_path)
|
| 113 |
+
logger.info("Qdrant Local Client initialized.")
|
| 114 |
+
|
| 115 |
+
def _init_qdrant_server(self, qdrant_url: str):
|
| 116 |
+
"""Initialize Qdrant in Server Mode — connect and restore snapshots if needed."""
|
| 117 |
+
logger.info(f"Connecting to Qdrant Server at {qdrant_url}")
|
| 118 |
+
self.client = qdrant_client.QdrantClient(url=qdrant_url)
|
| 119 |
+
|
| 120 |
+
try:
|
| 121 |
+
all_cols = [c.name for c in self.client.get_collections().collections]
|
| 122 |
+
except Exception as e:
|
| 123 |
+
logger.error(f"Failed to list collections: {e}")
|
| 124 |
+
all_cols = []
|
| 125 |
+
|
| 126 |
+
for col_name in self.collections:
|
| 127 |
+
actual_col = None
|
| 128 |
+
if col_name in all_cols:
|
| 129 |
+
actual_col = col_name
|
| 130 |
+
else:
|
| 131 |
+
matches = [c for c in all_cols if c.startswith(f"{col_name}_") or c.startswith(col_name)]
|
| 132 |
+
if matches:
|
| 133 |
+
actual_col = matches[0]
|
| 134 |
+
|
| 135 |
+
if actual_col:
|
| 136 |
+
if col_name == "tipitaka_chunks":
|
| 137 |
+
self.actual_chunks_col = actual_col
|
| 138 |
+
else:
|
| 139 |
+
snap_filename = f"{col_name}.snapshot"
|
| 140 |
+
target_snap = ALLOWED_SNAP_ROOT / snap_filename
|
| 141 |
+
if not target_snap.exists():
|
| 142 |
+
target_snap = self.snapshot_dir / snap_filename
|
| 143 |
+
|
| 144 |
+
if target_snap.exists():
|
| 145 |
+
import os
|
| 146 |
+
logger.info(f"Restoring server collection '{col_name}' from {target_snap}...")
|
| 147 |
+
abs_snap_path = os.path.abspath(target_snap).replace("\\", "/")
|
| 148 |
+
if not abs_snap_path.startswith("/"):
|
| 149 |
+
abs_snap_path = "/" + abs_snap_path
|
| 150 |
+
try:
|
| 151 |
+
self.client.recover_snapshot(col_name, location=f"file://{abs_snap_path}")
|
| 152 |
+
if col_name == "tipitaka_chunks":
|
| 153 |
+
self.actual_chunks_col = col_name
|
| 154 |
+
except Exception as e:
|
| 155 |
+
logger.error(f"Failed to restore: {e}")
|
| 156 |
+
|
| 157 |
def _load_model(self):
|
| 158 |
"""Verify Ollama embedding service is accessible."""
|
| 159 |
if self.model is None:
|
|
|
|
| 181 |
self.reranker = "error"
|
| 182 |
|
| 183 |
def _get_embedding(self, text: str) -> list:
|
| 184 |
+
"""Get embedding vector from Ollama API, with LRU cache."""
|
| 185 |
+
# Check LRU cache first
|
| 186 |
+
if text in self._embed_cache:
|
| 187 |
+
self._embed_cache.move_to_end(text) # Mark as recently used
|
| 188 |
+
return self._embed_cache[text]
|
| 189 |
+
|
| 190 |
response = httpx.post(
|
| 191 |
f"{OLLAMA_URL}/api/embed",
|
| 192 |
json={"model": EMBED_MODEL, "input": text},
|
| 193 |
timeout=30
|
| 194 |
)
|
| 195 |
response.raise_for_status()
|
| 196 |
+
embedding = response.json()["embeddings"][0]
|
| 197 |
+
|
| 198 |
+
# Store in LRU cache
|
| 199 |
+
self._embed_cache[text] = embedding
|
| 200 |
+
if len(self._embed_cache) > self._embed_cache_max:
|
| 201 |
+
self._embed_cache.popitem(last=False) # Remove oldest (LRU)
|
| 202 |
+
|
| 203 |
+
return embedding
|
| 204 |
|
| 205 |
async def query(self, text: str, n_results: int = 10, threshold: float = 0.2) -> str:
|
| 206 |
"""
|
webapp/tipitaka-api/app/services/search_service.py
CHANGED
|
@@ -1,279 +1,15 @@
|
|
| 1 |
import time
|
| 2 |
import re
|
| 3 |
-
from difflib import SequenceMatcher
|
| 4 |
from typing import List
|
| 5 |
from app.database.sqlite_db import SQLiteDB
|
| 6 |
from app.schemas import SearchResponse, SearchResultItem
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
HAS_PYTHAINLP = False
|
| 15 |
-
|
| 16 |
-
# ── Thai Pali Character Confusion Map ─────────────────────────────
|
| 17 |
-
# These are character-level substitution errors commonly made when
|
| 18 |
-
# typing Pali terms in Thai script (คนไทยมักพิมพ์ส/ศ/ษ สลับกัน ฯลฯ)
|
| 19 |
-
THAI_PALI_CONFUSIONS = {
|
| 20 |
-
# ส group — all map to /s/ sound
|
| 21 |
-
"ส": ["ศ", "ษ"],
|
| 22 |
-
"ศ": ["ส", "ษ"],
|
| 23 |
-
"ษ": ["ส", "ศ"],
|
| 24 |
-
# ฏ group — retroflex / dental stops
|
| 25 |
-
"ฏ": ["ต", "ฐ"],
|
| 26 |
-
"ฐ": ["ต", "ฏ", "ถ"],
|
| 27 |
-
"ถ": ["ต", "ฐ", "ฑ"],
|
| 28 |
-
"ฑ": ["ต", "ฐ", "ถ", "ฒ"],
|
| 29 |
-
"ฒ": ["ด", "ฑ", "ท"],
|
| 30 |
-
# ณ / น
|
| 31 |
-
"ณ": ["น"],
|
| 32 |
-
"น": ["ณ"],
|
| 33 |
-
# ภ / พ
|
| 34 |
-
"ภ": ["พ"],
|
| 35 |
-
"พ": ["ภ"],
|
| 36 |
-
# ฬ / ล
|
| 37 |
-
"ฬ": ["ล"],
|
| 38 |
-
"ล": ["ฬ"],
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def _generate_pali_variants(word: str) -> set[str]:
|
| 43 |
-
"""Generate common misspellings for a Pali term via single-character substitutions.
|
| 44 |
-
|
| 45 |
-
For each character in `word`, if it belongs to a confusion group, replace it
|
| 46 |
-
with each alternative to produce a likely misspelling. Only single-character
|
| 47 |
-
changes are generated (not combinatorial) to keep the map lean.
|
| 48 |
-
"""
|
| 49 |
-
variants: set[str] = set()
|
| 50 |
-
for i, ch in enumerate(word):
|
| 51 |
-
if ch in THAI_PALI_CONFUSIONS:
|
| 52 |
-
for alt in THAI_PALI_CONFUSIONS[ch]:
|
| 53 |
-
variant = word[:i] + alt + word[i + 1:]
|
| 54 |
-
if variant != word:
|
| 55 |
-
variants.add(variant)
|
| 56 |
-
return variants
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
def _pythai_autocorrect(q: str) -> str:
|
| 60 |
-
"""Use PyThaiNLP to correct general Thai spelling errors (not Pali-specific).
|
| 61 |
-
|
| 62 |
-
Falls back to the original query if PyThaiNLP is unavailable or raises.
|
| 63 |
-
Only applies corrections when at least one token changes.
|
| 64 |
-
"""
|
| 65 |
-
if not HAS_PYTHAINLP or not q:
|
| 66 |
-
return q
|
| 67 |
-
try:
|
| 68 |
-
# Tokenize then spell-correct each token independently
|
| 69 |
-
tokens = pythai_tokenize(q)
|
| 70 |
-
corrected_tokens = []
|
| 71 |
-
changed = False
|
| 72 |
-
for tok in tokens:
|
| 73 |
-
if len(tok) <= 1: # Skip single-char tokens (spaces, punctuation)
|
| 74 |
-
corrected_tokens.append(tok)
|
| 75 |
-
continue
|
| 76 |
-
suggestions = pythai_spell_correct(tok)
|
| 77 |
-
if suggestions and suggestions[0] != tok:
|
| 78 |
-
corrected_tokens.append(suggestions[0])
|
| 79 |
-
changed = True
|
| 80 |
-
else:
|
| 81 |
-
corrected_tokens.append(tok)
|
| 82 |
-
if changed:
|
| 83 |
-
return "".join(corrected_tokens)
|
| 84 |
-
except Exception:
|
| 85 |
-
pass
|
| 86 |
-
return q
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def _similar_enough(original: str, corrected: str) -> bool:
|
| 90 |
-
"""Check if PyThaiNLP correction is reasonably similar to the original.
|
| 91 |
-
|
| 92 |
-
PyThaiNLP uses a general Thai dictionary and does NOT understand Pali
|
| 93 |
-
specialized vocabulary. It can aggressively "correct" Pali technical terms
|
| 94 |
-
into unrelated Thai words.
|
| 95 |
-
|
| 96 |
-
Examples of destructive corrections we reject:
|
| 97 |
-
ปุพเพนิวาสานุสสติญาณ (20 chars) → ปพเนวสสตญ (9 chars) — dropped 55% of text
|
| 98 |
-
สติปัฏฐาน (9 chars) → correct variant (still ~9 chars) — accepted
|
| 99 |
-
|
| 100 |
-
We use TWO independent guards:
|
| 101 |
-
1. Length ratio: corrected must be ≥45% of original length (rejects dropped chars)
|
| 102 |
-
2. SequenceMatcher ratio: ≥0.65 character overlap (rejects garbled text)
|
| 103 |
-
|
| 104 |
-
Both must pass for the correction to be accepted.
|
| 105 |
-
"""
|
| 106 |
-
if original == corrected or not original or not corrected:
|
| 107 |
-
return True
|
| 108 |
-
# Guard 1: Length ratio — if corrected is drastically shorter, it's a lossy "correction"
|
| 109 |
-
len_ratio = min(len(original), len(corrected)) / max(len(original), len(corrected))
|
| 110 |
-
if len_ratio < 0.45:
|
| 111 |
-
return False
|
| 112 |
-
# Guard 2: Edit-distance ratio — actual character overlap
|
| 113 |
-
# SequenceMatcher.ratio() = 2*M / (len(a) + len(b)) where M = matching chars
|
| 114 |
-
if SequenceMatcher(None, original, corrected, autojunk=False).ratio() < 0.65:
|
| 115 |
-
return False
|
| 116 |
-
return True
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
# ── Pali/Dhamma Auto-correction (full list from v2.1) ─────────────
|
| 120 |
-
PALI_CORRECTIONS: dict[str, str] = {
|
| 121 |
-
# อริยสัจ
|
| 122 |
-
"อริยะสัจ": "อริยสัจ", "อริยะสัจ4": "อริยสัจ", "อริยสัจ4": "อริยสัจ ๔",
|
| 123 |
-
"สี่อริยสัจ": "อริยสัจ", "4อริยสัจ": "อริยสัจ",
|
| 124 |
-
# นิพพาน
|
| 125 |
-
"นิพพาณ": "นิพพาน", "นิพาน": "นิพพาน", "นิรพาน": "นิพพาน", "นิบาน": "นิพพาน",
|
| 126 |
-
# กรรม / ธรรม
|
| 127 |
-
"กรม": "กรรม", "ธรม": "ธรรม", "ธรรมะ": "ธรรม",
|
| 128 |
-
# สังสารวัฏ
|
| 129 |
-
"สังสาวัฎ": "สังสารวัฏ", "วัฎสังสาร": "สังสารวัฏ", "สังสารวัฎ": "สังสารวัฏ",
|
| 130 |
-
# ไตรลักษณ์
|
| 131 |
-
"ไตรลักณ์": "ไตรลักษณ์", "ไตรลักษน์": "ไตรลักษณ์", "ไตรลัษณ์": "ไตรลักษณ์",
|
| 132 |
-
"3ลักษณะ": "ไตรลักษณ์", "สามลักษณะ": "ไตรลักษณ์",
|
| 133 |
-
# อนิจจัง / ทุกขัง / อนัตตา
|
| 134 |
-
"อนิตย์": "อนิจจัง", "อนิตยา": "อนิจจัง",
|
| 135 |
-
"ทุกขะ": "ทุกข์",
|
| 136 |
-
"อนัตตะ": "อนัตตา", "อนาตา": "อนัตตา",
|
| 137 |
-
# สติปัฏฐาน
|
| 138 |
-
"สติปฏฐาน": "สติปัฏฐาน", "สติปัฐฐาน": "สติปัฏฐาน", "สติปัฏฐาน4": "สติปัฏฐาน ๔",
|
| 139 |
-
# มรรค
|
| 140 |
-
"มัก": "มรรค", "มาก": "มรรค",
|
| 141 |
-
"อัฎฐมรรค": "อัฏฐมรรค", "มรรค8": "อัฏฐมรรค",
|
| 142 |
-
"8มรรค": "อัฏฐมรรค", "มรรคแปด": "อัฏฐมรรค",
|
| 143 |
-
# โพชฌงค์
|
| 144 |
-
"โพชงค์": "โพชฌงค์", "โพชฌงค7": "โพชฌงค์",
|
| 145 |
-
# ปฏิจจสมุปบาท
|
| 146 |
-
"ปฏิจสมุปบาท": "ปฏิจจสมุปบาท", "ปฎิจจสมุปบาท": "ปฏิจจสมุปบาท",
|
| 147 |
-
"ปัจจยาการ": "ปฏิจจสมุปบาท",
|
| 148 |
-
# กิเลส / ขันธ์
|
| 149 |
-
"กิเลษ": "กิเลส", "กิเล็ส": "กิเลส",
|
| 150 |
-
"ขัน5": "ขันธ์ ๕", "5ขันธ์": "ขันธ์ ๕", "ขันธ์5": "ขันธ์ ๕", "ขันห์": "ขันธ์",
|
| 151 |
-
# โพธิ / วินัย / อภิธรรม
|
| 152 |
-
"โพธิ์": "โพธิ", "โพธิ์สัตว์": "โพธิสัตว์", "พระโพธิ์สัตว์": "โพธิสัตว์",
|
| 153 |
-
"วินัยปิฏก": "วินัยปิฎก", "วินัยปิฎฎก": "วินัยปิฎก",
|
| 154 |
-
"อภิธรม": "อภิธรรม", "อภิธัมม์": "อภิธรรม",
|
| 155 |
-
# สุตตันตปิฎก
|
| 156 |
-
"สุตันตปิฎก": "สุตตันตปิฎก", "สูตรปิฎก": "สุตตันตปิฎก",
|
| 157 |
-
# สมาธิ / วิปัสสนา / สมถะ
|
| 158 |
-
"สมาธิ์": "สมาธิ",
|
| 159 |
-
"วิปัสนา": "วิปัสสนา", "วิปาสสนา": "วิปัสสนา", "วิปสสนา": "วิปัสสนา",
|
| 160 |
-
"สมถ": "สมถะ", "สมธะ": "สมถะ",
|
| 161 |
-
# ปรินิพพาน
|
| 162 |
-
"ปรินิพาน": "ปรินิพพาน", "ปรินิพพาณ": "ปรินิพพาน",
|
| 163 |
-
# มรณสติ
|
| 164 |
-
"มรนสติ": "มรณสติ",
|
| 165 |
-
# มหาบุรุษ
|
| 166 |
-
"มหาบุรษ": "มหาบุรุษ",
|
| 167 |
-
}
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
def to_thai_digits(s: str) -> str:
|
| 171 |
-
return s.translate(str.maketrans("0123456789", "๐๑๒๓๔๕๖๗๘๙"))
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
# ── Curated popular terms (fallback when search_log is empty) ──────
|
| 175 |
-
# Source: https://84000.org/tipitaka/dic/ (topic headings, stripped of varga numbers)
|
| 176 |
-
POPULAR_TERMS: List[str] = [
|
| 177 |
-
# ── Core doctrines ──────────────────────────────────────────
|
| 178 |
-
"อริยสัจ", "นิพพาน", "ไตรลักษณ์", "อนิจจัง", "ทุกข์", "อนัตตา",
|
| 179 |
-
"สติปัฏฐาน", "อัฏฐมรรค", "โพชฌงค์", "ปฏิจจสมุปบาท",
|
| 180 |
-
"สมาธิ", "วิปัสสนา", "สมถะ", "ศีล", "ปัญญา",
|
| 181 |
-
"กรรม", "วิบาก", "กิเลส", "ตัณหา", "อวิชชา",
|
| 182 |
-
"โพธิสัตว์", "ปรินิพพาน", "สังสารวัฏ", "มรณสติ",
|
| 183 |
-
"เมตตา", "กรุณา", "มุทิตา", "อุเบกขา",
|
| 184 |
-
"อภิธรรม", "วินัยปิฎก", "สุตตันตปิฎก",
|
| 185 |
-
"ขันธ์", "อายตนะ", "ธาตุ", "อินทรีย์",
|
| 186 |
-
"โ���ธิปักขิยธรรม", "สัมมาสมาธิ", "สัมมาสติ",
|
| 187 |
-
"บารมี", "เจตสิก", "จิต", "รูป", "นาม",
|
| 188 |
-
# ── From 84000 dictionary ───────────────────────────────────
|
| 189 |
-
"กถาวัตถุ", "กรรมกิเลส", "กรรมฐาน", "กสิณ",
|
| 190 |
-
"กัลยาณมิตตตา", "กัลยาณมิตรธรรม", "กาม", "กามคุณ",
|
| 191 |
-
"กามโภคี", "กาลามสูตรกังขานิยฐาน", "กิจ", "กิจในอริยสัจจ์",
|
| 192 |
-
"กุลจิรัฏฐิติธรรม", "กุศลกรรมบถ", "กุศลมูล", "กุศลวิตก",
|
| 193 |
-
"คารวะ", "ฆราวาสธรรม", "จรณะ", "จริต", "จักขุ",
|
| 194 |
-
"จักร", "จักรวรรดิวัตร", "ฌาน", "ฌาน 2 ประเภท", "ญาณ",
|
| 195 |
-
"ถูปารหบุคคล", "ทวาร", "ทศพลญาณ", "ทักขิณาวิสุทธิ",
|
| 196 |
-
"ทาน", "ทิฏฐธัมมิกัตถสังวัตตนิกธรรม", "ทิฏฐิ", "ทิศ",
|
| 197 |
-
"ที่สุด", "ทุกขตา", "ทุจริต", "ธรรม", "ธรรมขันธ์",
|
| 198 |
-
"ธรรมคุณ", "ธรรมคุ้มครองโลก", "ธรรมทำให้งาม", "ธรรมนิยาม",
|
| 199 |
-
"ธรรมมีอุปการะมาก", "ธรรมสมาทาน", "ธรรมสมาธิ",
|
| 200 |
-
"ธรรมสวนานิสงส์", "ธรรมเทสกธรรม", "ธาตุกัมมัฏฐาน",
|
| 201 |
-
"ธุดงค์", "ธุระ", "นวกภิกขุธรรม", "นวังคสัตถุสาสน์",
|
| 202 |
-
"นาถกรณธรรม", "นิมิต", "นิยาม", "นิวรณ์", "นิโรธ",
|
| 203 |
-
"บริษัท", "บัญญัติ 2", "บุคคล", "บุคคลหาได้ยาก",
|
| 204 |
-
"บุญกิริยาวัตถุ", "บุตร", "บุพนิมิตแห่งมรรค", "บูชา",
|
| 205 |
-
"ปฏิปทา", "ปฏิสันถาร", "ปฏิสัมภิทา", "ปธาน",
|
| 206 |
-
"ปปัญจะ, ปปัญจธรรม", "ปรมัตถธรรม", "ประมาณ", "ปริญญา",
|
| 207 |
-
"ปริเยสนา", "ปัจจัย", "ปัจจัยให้เกิดสัมมาทิฏฐิ",
|
| 208 |
-
"ปัพพชิตอภิณหปัจจเวกขณ์", "ปาฏิหาริย์", "ปาปณิกธรรม",
|
| 209 |
-
"ปาพจน์", "ปาริสุทธิศีล", "ปิยรูป สาตรูป", "ปีติ",
|
| 210 |
-
"ผล", "พร", "พรหมวิหาร", "พละ",
|
| 211 |
-
"พละ 5 ของพระมหากษัตริย์", "พหูสูตมีองค์", "พุทธคุณ",
|
| 212 |
-
"พุทธจริยา", "พุทธโอวาท", "ภพ", "ภรรยา",
|
| 213 |
-
"ภัพพตาธรรม", "ภาวนา", "ภูมิ", "มงคล", "มรรค",
|
| 214 |
-
"มรรคมีองค์", "มละ", "มหาปเทส",
|
| 215 |
-
"มหาปเทส 4 เฉพาะในทางพระวินัย", "มหาภูต", "มัจฉริยะ",
|
| 216 |
-
"มานะ", "มาร", "มิจฉัตตะ", "มิตรปฏิรูปก์",
|
| 217 |
-
"รัตนตรัย", "ราชธรรม", "ฤทธิ์", "ลักษณะตัดสินธรรมวินัย",
|
| 218 |
-
"ลัทธินอกพระพุทธศาสนา", "ลีลาการสอน", "วณิชชา", "วรรณะ",
|
| 219 |
-
"วัฏฏะ", "วัฒนมุข", "วัตถุประสงค์ในการบัญญัติวินัย",
|
| 220 |
-
"วิชชา", "วิญญาณ", "วิญญาณฐิติ",
|
| 221 |
-
"วิธีปฏิบัติต่อทุกข์-สุข", "วิบัติ", "วิปัลลาส",
|
| 222 |
-
"วิปัสสนาญาณ", "วิปัสสนูปกิเลส", "วิมุตติ", "วิรัติ",
|
| 223 |
-
"วิสุทธิ", "วิเวก", "วิโมกข์", "วุฒิ", "ศรัทธา",
|
| 224 |
-
"ศีล 8 ทั้งอาชีวะ", "สกทาคามี", "สมชีวิธรรม", "สมบัติ",
|
| 225 |
-
"สมาธิภาวนา", "สมาบัติ", "สรณะ", "สวรรค์",
|
| 226 |
-
"สังขตลักษณะ", "สังขาร", "สังคหวัตถุ",
|
| 227 |
-
"สังคหวัตถุของผู้ครองแผ่นดิน", "สังคหะ", "สังฆคุณ",
|
| 228 |
-
"สังวร", "���ังเวชนียสถาน", "สังโยชน์", "สัจจะ", "สัญญา",
|
| 229 |
-
"สัตตาวาส", "สัทธรรม", "สันโดษ", "สัปปายะ",
|
| 230 |
-
"สัปปุริสทาน", "สัปปุริสธรรม", "สัปปุริสบัญญัติ",
|
| 231 |
-
"สัมปชัญญะ", "สัมปทา", "สัมปรายิกัตถสังวัตตนิกธรรม",
|
| 232 |
-
"สัมผัส", "สัมมัตตะ", "สารณียธรรม", "สาสน์", "สิกขา",
|
| 233 |
-
"สุข", "สุขของคฤหัสถ์", "สุจริต", "สุทธาวาส", "สุทธิ",
|
| 234 |
-
"สุหทมิตร", "อกุศลกรรมบถ", "อกุศลมูล", "อกุศลวิตก",
|
| 235 |
-
"อคติ", "อธิปไตย", "อธิษฐาน", "อนันตริยกรรม",
|
| 236 |
-
"อนาคามี", "อนุตตริยะ", "อนุบุพพวิหาร", "อนุปุพพิกถา",
|
| 237 |
-
"อนุสติ", "อนุสัย", "อบาย", "อบายมุข",
|
| 238 |
-
"อปริหานิยธรรม", "อปัณณกปฏิปทา", "อปัสเสนะ",
|
| 239 |
-
"อภิญญา", "อภิฐาน", "อภิณหปัจจเวกขณ์", "อภิสังขาร",
|
| 240 |
-
"อรหันต์", "อริยทรัพย์", "อริยบุคคล", "อริยวงศ์",
|
| 241 |
-
"อริยวัฑฒิ", "อริยสัจจ์", "อรูป", "อสังขตลักษณะ",
|
| 242 |
-
"อสุภะ", "อัคคิ", "อัตถะ", "อันตคาหิกทิฏฐิ",
|
| 243 |
-
"อันตรายของภิกษุสามเณรผู้บวชใหม่", "อัปปมาทะ",
|
| 244 |
-
"อาการที่พระพุทธเจ้าทรงสั่งสอน", "อาจารย์",
|
| 245 |
-
"อานาปานสติ 16 ฐาน", "อายตนะภายนอก", "อายตนะภายใน",
|
| 246 |
-
"อายุสสธรรม", "อาวาสิกธรรม", "อาสวะ", "อาหาร",
|
| 247 |
-
"อิทธิบาท", "อุบาสกธรรม", "อุปกิเลส", "อุปัญญาตธรรม",
|
| 248 |
-
"อุปาทาน", "อุปาทารูป", "เจดีย์", "เจตนา", "เถรธรรม",
|
| 249 |
-
"เทพ", "เทวทูต", "เทศนา", "เบญจธรรม", "เมถุนสังโยค",
|
| 250 |
-
"เวทนา", "เวปุลลธรรม", "เวปุลละ", "เวสารัชชกรณธรรม",
|
| 251 |
-
"เวสารัชชะ", "โกศล", "โภควิภาค", "โภคอาทิยะ",
|
| 252 |
-
"โยคะ", "โยนิ", "โยนิโสมนสิการ", "โลก", "โลกธรรม",
|
| 253 |
-
"โลกุตตรธรรม", "โสดาบัน", "โสตาปัตติยังคะ", "โอฆะ",
|
| 254 |
-
"ไตรปิฎก",
|
| 255 |
-
]
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
def _build_pali_corrections() -> dict[str, str]:
|
| 259 |
-
"""Extend PALI_CORRECTIONS with auto-generated variants from all POPULAR_TERMS.
|
| 260 |
-
|
| 261 |
-
For every term in POPULAR_TERMS, this generates likely misspellings
|
| 262 |
-
using character confusion groups (ส↔ศ↔ษ, ฏ↔ต↔ฐ, ณ↔น, ภ↔พ, ฬ↔ล, etc.)
|
| 263 |
-
and adds them to the corrections dict.
|
| 264 |
-
|
| 265 |
-
This is called once at module load time so there is zero runtime cost.
|
| 266 |
-
"""
|
| 267 |
-
corrections = dict(PALI_CORRECTIONS)
|
| 268 |
-
for term in POPULAR_TERMS:
|
| 269 |
-
for variant in _generate_pali_variants(term):
|
| 270 |
-
if variant not in corrections:
|
| 271 |
-
corrections[variant] = term
|
| 272 |
-
return corrections
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
# Build the full corrections dict at load time (hand-curated + auto-generated)
|
| 276 |
-
PALI_CORRECTIONS_FULL = _build_pali_corrections()
|
| 277 |
|
| 278 |
|
| 279 |
class SearchService:
|
|
@@ -350,7 +86,7 @@ class SearchService:
|
|
| 350 |
continue
|
| 351 |
if re.match(r"^[_\-=]{3,}$", ln.strip()): # separator lines
|
| 352 |
continue
|
| 353 |
-
ln = re.sub(r'[๐๑๒๓๔๕๖๗๘๙]+-', '', ln) # remove Thai numeral footnote markers
|
| 354 |
ln = re.sub(r" +", " ", ln).strip()
|
| 355 |
if ln:
|
| 356 |
lines.append(ln)
|
|
@@ -407,13 +143,10 @@ class SearchService:
|
|
| 407 |
# ── 1. Auto-correct pipeline ─────────────────────────────
|
| 408 |
q = query.strip()
|
| 409 |
|
| 410 |
-
# Stage A: Pali-specific correction (explicit dict
|
| 411 |
corrected_q = PALI_CORRECTIONS_FULL.get(q, q)
|
| 412 |
|
| 413 |
# Stage B: General Thai spelling correction via PyThaiNLP
|
| 414 |
-
# NOTE: PyThaiNLP does NOT understand Pali — it uses a general Thai
|
| 415 |
-
# dictionary and can corrupt Pali terms into unrelated words. We guard
|
| 416 |
-
# with _similar_enough() to reject destructive "corrections".
|
| 417 |
if corrected_q == q:
|
| 418 |
pythai_q = _pythai_autocorrect(q)
|
| 419 |
if pythai_q != q and _similar_enough(q, pythai_q):
|
|
@@ -425,7 +158,7 @@ class SearchService:
|
|
| 425 |
with self.db.get_connection() as conn:
|
| 426 |
cursor = conn.cursor()
|
| 427 |
|
| 428 |
-
# ── 2. FTS5 search
|
| 429 |
fts_query = f'"{thai_q}"' if "*" not in thai_q else thai_q
|
| 430 |
try:
|
| 431 |
cursor.execute("""
|
|
|
|
| 1 |
import time
|
| 2 |
import re
|
|
|
|
| 3 |
from typing import List
|
| 4 |
from app.database.sqlite_db import SQLiteDB
|
| 5 |
from app.schemas import SearchResponse, SearchResultItem
|
| 6 |
+
from app.services.pali_utils import (
|
| 7 |
+
PALI_CORRECTIONS_FULL,
|
| 8 |
+
POPULAR_TERMS,
|
| 9 |
+
to_thai_digits,
|
| 10 |
+
_pythai_autocorrect,
|
| 11 |
+
_similar_enough,
|
| 12 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
class SearchService:
|
|
|
|
| 86 |
continue
|
| 87 |
if re.match(r"^[_\-=]{3,}$", ln.strip()): # separator lines
|
| 88 |
continue
|
| 89 |
+
ln = re.sub(r'[๐๑๒๓๔๕๖๗๘๙]+-', '', ln) # remove Thai numeral footnote markers
|
| 90 |
ln = re.sub(r" +", " ", ln).strip()
|
| 91 |
if ln:
|
| 92 |
lines.append(ln)
|
|
|
|
| 143 |
# ── 1. Auto-correct pipeline ─────────────────────────────
|
| 144 |
q = query.strip()
|
| 145 |
|
| 146 |
+
# Stage A: Pali-specific correction (explicit dict + auto-generated variants)
|
| 147 |
corrected_q = PALI_CORRECTIONS_FULL.get(q, q)
|
| 148 |
|
| 149 |
# Stage B: General Thai spelling correction via PyThaiNLP
|
|
|
|
|
|
|
|
|
|
| 150 |
if corrected_q == q:
|
| 151 |
pythai_q = _pythai_autocorrect(q)
|
| 152 |
if pythai_q != q and _similar_enough(q, pythai_q):
|
|
|
|
| 158 |
with self.db.get_connection() as conn:
|
| 159 |
cursor = conn.cursor()
|
| 160 |
|
| 161 |
+
# ── 2. FTS5 search ──────────────────────────────────────
|
| 162 |
fts_query = f'"{thai_q}"' if "*" not in thai_q else thai_q
|
| 163 |
try:
|
| 164 |
cursor.execute("""
|
webapp/tipitaka-api/requirements.txt
CHANGED
|
@@ -14,3 +14,5 @@ transformers>=4.40.0
|
|
| 14 |
numpy>=1.24.0
|
| 15 |
httpx>=0.27.0
|
| 16 |
anyio>=4.0.0
|
|
|
|
|
|
|
|
|
| 14 |
numpy>=1.24.0
|
| 15 |
httpx>=0.27.0
|
| 16 |
anyio>=4.0.0
|
| 17 |
+
pytest>=8.0
|
| 18 |
+
pytest-asyncio>=0.24
|
webapp/tipitaka-api/tests/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tipitaka API — test suite.
|
| 3 |
+
"""
|
webapp/tipitaka-api/tests/conftest.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shared test fixtures for Tipitaka API tests.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from unittest.mock import MagicMock
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@pytest.fixture
|
| 10 |
+
def mock_db():
|
| 11 |
+
"""Create a mock SQLiteDB instance for service testing."""
|
| 12 |
+
db = MagicMock()
|
| 13 |
+
return db
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@pytest.fixture
|
| 17 |
+
def sample_query():
|
| 18 |
+
return "อริยสัจ"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture
|
| 22 |
+
def sample_query_misspelled():
|
| 23 |
+
return "อริยะสัจ"
|
webapp/tipitaka-api/tests/test_pali_utils.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for pali_utils — Thai/Pali text utilities.
|
| 3 |
+
|
| 4 |
+
Tests cover:
|
| 5 |
+
- Thai digit conversion
|
| 6 |
+
- Pali confusion map variant generation
|
| 7 |
+
- PyThaiNLP autocorrect (with fallback when library is absent)
|
| 8 |
+
- Pali correction dictionary lookup
|
| 9 |
+
- Full correction dict generation
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
from app.services.pali_utils import (
|
| 14 |
+
to_thai_digits,
|
| 15 |
+
THAI_PALI_CONFUSIONS,
|
| 16 |
+
_generate_pali_variants,
|
| 17 |
+
PALI_CORRECTIONS,
|
| 18 |
+
PALI_CORRECTIONS_FULL,
|
| 19 |
+
POPULAR_TERMS,
|
| 20 |
+
HAS_PYTHAINLP,
|
| 21 |
+
_similar_enough,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class TestToThaiDigits:
|
| 26 |
+
def test_converts_arabic_to_thai(self):
|
| 27 |
+
assert to_thai_digits("123") == "๑๒๓"
|
| 28 |
+
|
| 29 |
+
def test_handles_empty(self):
|
| 30 |
+
assert to_thai_digits("") == ""
|
| 31 |
+
|
| 32 |
+
def test_handles_mixed(self):
|
| 33 |
+
assert to_thai_digits("มรรค8") == "มรรค๘"
|
| 34 |
+
|
| 35 |
+
def test_leaves_thai_digits_unchanged(self):
|
| 36 |
+
assert to_thai_digits("๑๒๓") == "๑๒๓"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class TestPaliConfusions:
|
| 40 |
+
def test_confusion_map_has_expected_keys(self):
|
| 41 |
+
assert "ส" in THAI_PALI_CONFUSIONS
|
| 42 |
+
assert "ณ" in THAI_PALI_CONFUSIONS
|
| 43 |
+
assert "ภ" in THAI_PALI_CONFUSIONS
|
| 44 |
+
|
| 45 |
+
def test_generate_variants(self):
|
| 46 |
+
variants = _generate_pali_variants("สติ")
|
| 47 |
+
assert "ศติ" in variants or "ษติ" in variants
|
| 48 |
+
|
| 49 |
+
def test_generate_variants_no_confusion(self):
|
| 50 |
+
"""Words without confusion characters should produce no variants."""
|
| 51 |
+
variants = _generate_pali_variants("ธรรม")
|
| 52 |
+
assert len(variants) == 0 or all(v != "ธรรม" for v in variants)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class TestPaliCorrections:
|
| 56 |
+
def test_basic_correction(self):
|
| 57 |
+
assert PALI_CORRECTIONS["อริยะสัจ"] == "อริยสัจ"
|
| 58 |
+
|
| 59 |
+
def test_niphan_variants(self):
|
| 60 |
+
assert PALI_CORRECTIONS["นิพพาณ"] == "นิพพาน"
|
| 61 |
+
assert PALI_CORRECTIONS["นิพาน"] == "นิพพาน"
|
| 62 |
+
|
| 63 |
+
def test_full_dict_includes_hand_curated(self):
|
| 64 |
+
assert PALI_CORRECTIONS_FULL["อริยะสัจ"] == "อริยสัจ"
|
| 65 |
+
assert PALI_CORRECTIONS_FULL["นิพพาณ"] == "นิพพาน"
|
| 66 |
+
|
| 67 |
+
def test_full_dict_includes_auto_generated(self):
|
| 68 |
+
"""POPULAR_TERMS should have auto-generated variants via confusion map."""
|
| 69 |
+
if "อริยสัจ" in POPULAR_TERMS:
|
| 70 |
+
has_variant = any(
|
| 71 |
+
k != "อริยสัจ" and PALI_CORRECTIONS_FULL.get(k) == "อริยสัจ"
|
| 72 |
+
for k in PALI_CORRECTIONS_FULL
|
| 73 |
+
)
|
| 74 |
+
assert has_variant, "Expected auto-generated variants for อริยสัจ"
|
| 75 |
+
|
| 76 |
+
def test_to_thai_digits_in_corrections(self):
|
| 77 |
+
"""Ensure numbered terms have both digit formats."""
|
| 78 |
+
assert "ขัน5" in PALI_CORRECTIONS
|
| 79 |
+
assert PALI_CORRECTIONS["ขัน5"] == "ขันธ์ ๕"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class TestSimilarEnough:
|
| 83 |
+
def test_identical(self):
|
| 84 |
+
assert _similar_enough("สติปัฏฐาน", "สติปัฏฐาน") is True
|
| 85 |
+
|
| 86 |
+
def test_similar_passes(self):
|
| 87 |
+
# Minor variation should pass
|
| 88 |
+
assert _similar_enough("สติปัฏฐาน", "สติปัฎฐาน") is True
|
| 89 |
+
|
| 90 |
+
def test_vastly_different_fails(self):
|
| 91 |
+
# Very different should fail
|
| 92 |
+
assert _similar_enough("ปุพเพนิวาสานุสสติญาณ", "ปพเนวสสตญ") is False
|
| 93 |
+
|
| 94 |
+
def test_empty(self):
|
| 95 |
+
assert _similar_enough("", "") is True
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class TestPopularTerms:
|
| 99 |
+
def test_popular_terms_is_list_of_strings(self):
|
| 100 |
+
assert isinstance(POPULAR_TERMS, list)
|
| 101 |
+
assert len(POPULAR_TERMS) > 0
|
| 102 |
+
assert all(isinstance(t, str) for t in POPULAR_TERMS)
|
| 103 |
+
|
| 104 |
+
def test_includes_core_doctrines(self):
|
| 105 |
+
core = {"อริยสัจ", "นิพพาน", "ไตรลักษณ์", "สติปัฏฐาน"}
|
| 106 |
+
assert core.issubset(set(POPULAR_TERMS))
|
webapp/tipitaka-api/tests/test_rag_service.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for RAGService — embedding cache, Qdrant client.
|
| 3 |
+
|
| 4 |
+
Uses mocked dependencies to test in isolation.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
from unittest.mock import MagicMock, patch
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class TestEmbeddingCache:
|
| 12 |
+
@pytest.fixture
|
| 13 |
+
def rag_service(self):
|
| 14 |
+
from app.services.rag_service import RAGService
|
| 15 |
+
with patch.object(RAGService, '_load_model'), \
|
| 16 |
+
patch.object(RAGService, '_load_reranker'), \
|
| 17 |
+
patch.object(RAGService, '_init_qdrant'):
|
| 18 |
+
service = RAGService()
|
| 19 |
+
service.client = MagicMock()
|
| 20 |
+
return service
|
| 21 |
+
|
| 22 |
+
def test_cache_initialized(self, rag_service):
|
| 23 |
+
assert rag_service._embed_cache is not None
|
| 24 |
+
assert rag_service._embed_cache_max == 256
|
| 25 |
+
|
| 26 |
+
def test_cache_stores_and_returns(self, rag_service):
|
| 27 |
+
"""Cache should store and return the same value for identical input."""
|
| 28 |
+
mock_response = MagicMock()
|
| 29 |
+
mock_response.json.return_value = {"embeddings": [[0.1, 0.2, 0.3]]}
|
| 30 |
+
mock_response.raise_for_status.return_value = None
|
| 31 |
+
|
| 32 |
+
with patch("app.services.rag_service.httpx.post", return_value=mock_response):
|
| 33 |
+
emb1 = rag_service._get_embedding("test query")
|
| 34 |
+
|
| 35 |
+
# Second call should use cache (no httpx call)
|
| 36 |
+
emb2 = rag_service._get_embedding("test query")
|
| 37 |
+
assert emb1 == emb2
|
| 38 |
+
|
| 39 |
+
def test_cache_max_respected(self, rag_service):
|
| 40 |
+
"""When cache exceeds max, oldest entries should be evicted."""
|
| 41 |
+
old_entry = "a" * 10
|
| 42 |
+
for i in range(rag_service._embed_cache_max + 5):
|
| 43 |
+
rag_service._embed_cache[f"key_{i}"] = [0.1] * 1024
|
| 44 |
+
if len(rag_service._embed_cache) > rag_service._embed_cache_max:
|
| 45 |
+
rag_service._embed_cache.popitem(last=False)
|
| 46 |
+
|
| 47 |
+
assert len(rag_service._embed_cache) <= rag_service._embed_cache_max
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class TestRAGServiceInit:
|
| 51 |
+
def test_init_graceful_on_qdrant_failure(self):
|
| 52 |
+
"""RAGService constructor should not crash when Qdrant is unavailable."""
|
| 53 |
+
from app.services.rag_service import RAGService
|
| 54 |
+
with patch.object(RAGService, '_load_model'), \
|
| 55 |
+
patch.object(RAGService, '_load_reranker'), \
|
| 56 |
+
patch.object(RAGService, '_init_qdrant') as mock_init:
|
| 57 |
+
service = RAGService()
|
| 58 |
+
mock_init.assert_called_once()
|
| 59 |
+
assert service.client is None # _init_qdrant didn't set it
|
webapp/tipitaka-api/tests/test_search_service.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for SearchService — FTS5, LIKE fallback, autocorrect pipeline.
|
| 3 |
+
|
| 4 |
+
Uses mocked SQLiteDB to test search logic in isolation.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
from unittest.mock import MagicMock, patch
|
| 9 |
+
from app.services.search_service import SearchService
|
| 10 |
+
from app.services.pali_utils import to_thai_digits
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class TestCleanText:
|
| 14 |
+
def setup_method(self):
|
| 15 |
+
self.service = SearchService(MagicMock())
|
| 16 |
+
|
| 17 |
+
def test_removes_line_numbers(self):
|
| 18 |
+
assert "ข้อความ" in self.service.clean_text("001 ข้อความ")
|
| 19 |
+
|
| 20 |
+
def test_removes_footnote_lines(self):
|
| 21 |
+
assert self.service.clean_text("@เชิงอรรถ") == ""
|
| 22 |
+
|
| 23 |
+
def test_removes_separators(self):
|
| 24 |
+
assert self.service.clean_text("_____") == ""
|
| 25 |
+
|
| 26 |
+
def test_preserves_normal_text(self):
|
| 27 |
+
text = "อริยสัจสี่ ประการ"
|
| 28 |
+
assert self.service.clean_text(text) == text
|
| 29 |
+
|
| 30 |
+
def test_handles_empty(self):
|
| 31 |
+
assert self.service.clean_text("") == ""
|
| 32 |
+
assert self.service.clean_text(None) == ""
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class TestMakeSnippet:
|
| 36 |
+
def setup_method(self):
|
| 37 |
+
self.service = SearchService(MagicMock())
|
| 38 |
+
|
| 39 |
+
def test_finds_keyword_and_returns_context(self):
|
| 40 |
+
text = "ดูกรภิกษุทั้งหลาย อริยสัจสี่ ประการเหล่านี้"
|
| 41 |
+
result = self.service.make_snippet(text, "อริยสัจ")
|
| 42 |
+
assert "อริยสัจ" in result
|
| 43 |
+
|
| 44 |
+
def test_max_length_repsected(self):
|
| 45 |
+
text = " ".join(["คำ"] * 100)
|
| 46 |
+
result = self.service.make_snippet(text, "คำ", max_len=100)
|
| 47 |
+
assert len(result) <= 100 + 20 # allow small expansion
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class TestHighlight:
|
| 51 |
+
def setup_method(self):
|
| 52 |
+
self.service = SearchService(MagicMock())
|
| 53 |
+
|
| 54 |
+
def test_wraps_keyword_in_mark(self):
|
| 55 |
+
result = self.service.highlight("อริยสัจสี่", "อริยสัจ")
|
| 56 |
+
assert "<mark>อริยสัจ</mark>" in result
|
| 57 |
+
|
| 58 |
+
def test_no_keyword_no_change(self):
|
| 59 |
+
result = self.service.highlight("ข้อความธรรมดา", "อริยสัจ")
|
| 60 |
+
assert result == "ข้อความธรรมดา"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class TestAutocorrectPipeline:
|
| 64 |
+
def setup_method(self):
|
| 65 |
+
self.service = SearchService(MagicMock())
|
| 66 |
+
|
| 67 |
+
def test_clean_text_no_side_effects(self):
|
| 68 |
+
"""Ensure clean_text doesn't modify input."""
|
| 69 |
+
text = "001 ข้อความปกติ"
|
| 70 |
+
result = self.service.clean_text(text)
|
| 71 |
+
assert result == "ข้อความปกติ"
|
| 72 |
+
|
| 73 |
+
def test_search_called_with_corrected_query(self):
|
| 74 |
+
"""Verify search method exists and runs (mocked DB will raise)."""
|
| 75 |
+
with pytest.raises(Exception):
|
| 76 |
+
# Will fail on DB mock because connection context manager
|
| 77 |
+
# but confirms the method signature is correct
|
| 78 |
+
self.service.search("อริยสัจ", limit=5)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class TestLogSearch:
|
| 82 |
+
def setup_method(self):
|
| 83 |
+
self.mock_db = MagicMock()
|
| 84 |
+
self.service = SearchService(self.mock_db)
|
| 85 |
+
|
| 86 |
+
def test_empty_query_skipped(self):
|
| 87 |
+
self.service.log_search("")
|
| 88 |
+
self.mock_db.get_disk_connection.assert_not_called()
|
| 89 |
+
|
| 90 |
+
def test_blank_query_skipped(self):
|
| 91 |
+
self.service.log_search(" ")
|
| 92 |
+
self.mock_db.get_disk_connection.assert_not_called()
|