dhammawatthumpra commited on
Commit
cb7868a
·
1 Parent(s): f50c177

refactor: clean legacy, sync deps, add embedding cache, split RAGService, split search_service, add tests

Browse files

1. 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 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
- logger.info(f"Initializing Qdrant in Local Mode at {self.qdrant_path}")
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
- logger.info(f"Connecting to Qdrant Server at {qdrant_url}")
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
- return response.json()["embeddings"][0]
 
 
 
 
 
 
 
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
- # ── Optional: PyThaiNLP for general Thai spell-checking ───────────
9
- try:
10
- from pythainlp.spell import correct as pythai_spell_correct
11
- from pythainlp.tokenize import word_tokenize as pythai_tokenize
12
- HAS_PYTHAINLP = True
13
- except ImportError:
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 (๑-, ๒-, etc.)
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 lookup + auto-generated variants)
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 (pages_fts — unified name) ──────────
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()