punsaisuwan commited on
Commit
208d6e0
·
verified ·
1 Parent(s): 13c1959

v2: Add router, persona enforcement, repetition guard logic

Browse files
Files changed (1) hide show
  1. moe_fixes.py +586 -0
moe_fixes.py ADDED
@@ -0,0 +1,586 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ moe_fixes.py
3
+ ============
4
+ รวม fix จาก Adversarial + Multi-turn Testing
5
+
6
+ Fix ล่าสุด (Round 7):
7
+ 1. เพิ่ม is_degenerate_query() + DEGENERATE_FALLBACK_RESPONSE — ตรวจจับ
8
+ Query ที่ไม่มีตัวอักษรที่มีความหมายเพียงพอ (ว่างเปล่า, มีแต่ Emoji,
9
+ มีแต่สัญลักษณ์เช่น "???!!!") ก่อนเข้า Pipeline ทั้งหมด
10
+ แก้ปัญหา: จาก Adversarial Test พบว่า Query ประเภทนี้ (Empty, Emoji-only,
11
+ Symbol-only) ถูกส่งเข้า LLM Classifier + Reasoning Expert + Translation
12
+ Layer เต็มขั้นตอนทุกครั้ง ทำให้ Latency สูงถึง 4-14 วินาทีโดยไม่จำเป็น
13
+ และเสี่ยง Hallucination (พบคำตอบเพี้ยนเรื่อง "crab" ที่ไม่เกี่ยวข้อง)
14
+ หรือ Language Leakage (พบคำว่า "riêng" หลุดเข้ามาในคำตอบภาษาไทย)
15
+ ใช้คู่กับ Fast-Path Guard ใน moe_orchestrator.py ที่ตอบกลับทันทีโดย
16
+ ไม่ต้อง Generate เลย (Latency ≈ 0s)
17
+
18
+ Fix รอบก่อนหน้า (Round 6 — คงเดิม):
19
+ 2. แก้ Bug: GREETING_CLOSING_WORDS ใช้ Substring Match ทำให้ "บาย" (bye)
20
+ จับ False Positive กับคำว่า "อธิบาย" (explain) — แยกเป็น 2 กลุ่มตาม
21
+ ความเสี่ยง (Safe Substring / ต้อง Exact Match)
22
+
23
+ Fix รอบก่อนหน้านั้น (Round 5 — คงเดิม):
24
+ 3. route_query() รับ previous_expert — History Continuation แทนการผ่าน
25
+ LLM Classifier ทุกครั้งที่ Query ไม่มี Keyword ชัดเจน
26
+ 4. is_code_refusal() — จับ Pattern ปฏิเสธงานที่ไม่สมเหตุสมผลของ
27
+ Python/TypeScript Expert คู่กับ Retry Logic ก่อนบันทึกเข้า History
28
+
29
+ Fix รอบก่อนหน้านั้น (คงเดิมทั้งหมด):
30
+ 5. ALLOWED_PATTERN รองรับ Emoji Unicode Range
31
+ 6. strip_translation_preamble() ใช้ตำแหน่งตัวอักษรไทยตัวแรก
32
+ 7. enforce_persona() บังคับ ผม/ครับ แบบ Deterministic
33
+ """
34
+
35
+ import re
36
+ from dataclasses import dataclass, field
37
+ from typing import Optional
38
+
39
+
40
+ # ======================================================================
41
+ # 0) DEGENERATE QUERY GUARD (Round 7: Fast-Path ก่อนเข้า Pipeline ทั้งหมด)
42
+ # ======================================================================
43
+
44
+ # จำนวนตัวอักษรที่มีความหมาย (ตัวหนังสือ/ตัวเลข/underscore ทุกภาษา) ขั้นต่ำ
45
+ # ที่ต้องมีถึงจะถือว่า Query นี้ "มีเนื้อหาให้ตอบจริง" — ต่ำกว่านี้ถือว่า
46
+ # Degenerate (ว่าง/มีแต่ Emoji/มีแต่สัญลักษณ์) ไม่ควรส่งเข้า Model เลย
47
+ MIN_MEANINGFUL_LENGTH = 2
48
+
49
+ # \w ใน Python 3 regex ครอบคลุม Unicode Word Character ทุกภาษาโดย Default
50
+ # (รวมภาษาไทย, อังกฤษ, ตัวเลข, underscore) ส่วน Emoji/สัญลักษณ์ทั้งหมด
51
+ # (Category So, Po, Sm ฯลฯ) จะถูกจับด้วย \W เพราะไม่ใช่ Word Character
52
+ _NON_MEANINGFUL_REGEX = re.compile(r'\W', flags=re.UNICODE)
53
+
54
+
55
+ def is_degenerate_query(query: str) -> bool:
56
+ """
57
+ คืนค่า True ถ้า Query ไม่มีตัวอักษรที่มีความหมายเพียงพอให้ตอบ
58
+ เช่น "", " ", "\\n\\n\\n", "🐍🐍🐍", "???!!!...", "?"
59
+ คืนค่า False ถ้ามีเนื้อหาจริง เช่น "help", "ทำไง", "def foo(): pass"
60
+ """
61
+ stripped = query.strip()
62
+ if len(stripped) == 0:
63
+ return True
64
+ meaningful_chars = _NON_MEANINGFUL_REGEX.sub('', stripped)
65
+ return len(meaningful_chars) < MIN_MEANINGFUL_LENGTH
66
+
67
+
68
+ DEGENERATE_FALLBACK_RESPONSE = (
69
+ "ขอโทษครับ ผมไม่แน่ใจว่าคุณต้องการถามอะไรครับ "
70
+ "ลองพิมพ์คำถามหรือรายละเอียดเพิ่มเติมให้ผมช่วยได้ไหมครับ"
71
+ )
72
+
73
+
74
+ # ======================================================================
75
+ # 0b) GREETING / CLOSING QUICK-REPLY (Round 8: ลด Latency ของ Small-talk)
76
+ # ======================================================================
77
+ # ต่างจาก is_degenerate_query() ตรงที่นี่ใช้ Exact-Match แบบเข้มงวด
78
+ # เพื่อป้องกันไม่ให้ Query ที่มีเนื้อหาจริงแซมมา (เช่น "ขอบคุณสำหรับ
79
+ # function นี้นะครับ") ถูก Quick-Reply ผิดพลาดจนพลาดเนื้อหาที่ User
80
+ # ต้องการคุยต่อจริงๆ — ใช้เฉพาะ Query ที่เป็น Greeting/Closing ล้วนๆ
81
+ # แก้ปัญหา: จาก Multi-turn Test พบว่า "สวัสดี" และ "ขอบคุณครับ" เสีย
82
+ # Latency 4.48s และ 10.06s เพราะผ่าน llm_classifier + reasoning +
83
+ # Translation Layer เต็มขั้นตอน ทั้งที่ไม่มีเนื้อหาให้ Generate จริง
84
+
85
+ _POLITE_SUFFIXES = ["ครับ", "ค่ะ", "จ้า", "นะครับ", "นะคะ", "น้า"]
86
+
87
+ GREETING_KEYWORDS = {"สวัสดี", "หวัดดี", "หวัดดีจ้า", "hi", "hello", "hey"}
88
+ CLOSING_KEYWORDS = {"บาย", "บ๊ายบาย", "ลาก่อน", "แล้วเจอกัน", "bye", "goodbye"}
89
+ THANKS_KEYWORDS = {"ขอบคุณ", "ขอบคุณมาก", "ขอบใจ", "thanks", "thank you", "thx"}
90
+
91
+ GREETING_QUICK_REPLY_TEXT = "สวัสดีครับ มีอะไรให้ผมช่วยไหมครับ"
92
+ CLOSING_QUICK_REPLY_TEXT = "ลาก่อนครับ แล้วพบกันใหม่นะครับ"
93
+ THANKS_QUICK_REPLY_TEXT = "ด้วยความยินดีครับ มีอะไรให้ผมช่วยเพิ่มอีกไหมครับ"
94
+
95
+
96
+ def _normalize_for_greeting_match(query: str) -> str:
97
+ text = query.strip()
98
+ text = re.sub(r'[!.?~๏๛]+$', '', text).strip()
99
+ for suffix in _POLITE_SUFFIXES:
100
+ if text.endswith(suffix):
101
+ text = text[: -len(suffix)].strip()
102
+ break
103
+ return text.lower()
104
+
105
+
106
+ def get_greeting_quick_reply(query: str) -> Optional[str]:
107
+ """
108
+ คืนค่า Canned Response ถ้า query เป็น Greeting/Closing/Thanks ล้วนๆ
109
+ (Exact Match หลังตัดคำลงท้ายสุภาพ) คืนค่า None ถ้ามีเนื้อหาอื่นแซมอยู่
110
+ หรือไม่เข้าเงื่อนไขใดเลย — เพื่อให้ระบบ Generate ตามปกติ
111
+ """
112
+ normalized = _normalize_for_greeting_match(query)
113
+ if not normalized:
114
+ return None
115
+ if normalized in GREETING_KEYWORDS:
116
+ return GREETING_QUICK_REPLY_TEXT
117
+ if normalized in CLOSING_KEYWORDS:
118
+ return CLOSING_QUICK_REPLY_TEXT
119
+ if normalized in THANKS_KEYWORDS:
120
+ return THANKS_QUICK_REPLY_TEXT
121
+ return None
122
+
123
+
124
+ # ======================================================================
125
+ # 0c) REPETITION LOOP DETECTOR (Round 9: ป้องกัน Generation Collapse)
126
+ # ======================================================================
127
+ # แก้ปัญหา: จาก Multi-turn Test พบว่าทั้ง Python Expert และ Reasoning
128
+ # Expert สามารถหลุดเข้า "Repetition Trap" ได้ — วนพูดวลีเดิมซ้ำๆไม่รู้จบ
129
+ # (เช่น "ผมขออภัยที่ทำให้คุณสับสน" ซ้ำ 50+ ครั้ง หรือรายชื่อ Google Service
130
+ # ซ้ำวนไม่จบ) จนชน max_tokens ทำให้ Latency พุ่งสูงสุดถึง 39.6 วินาที
131
+ # และคำตอบไม่มีเนื้อหาที่ต้องการเลย เกิดข้าม Expert คนละตัว ยืนยันว่าเป็น
132
+ # ปัญหาระดับ Sampler Configuration ไม่ใช่ปัญหาเฉพาะ LoRA Adapter ตัวใด
133
+
134
+ _REPETITION_LOOP_REGEX = re.compile(r'(.{6,50}?)\1{3,}', re.DOTALL)
135
+
136
+
137
+ def is_repetitive_response(text: str) -> bool:
138
+ """
139
+ คืนค่า True ถ้าพบ Substring ความยาว 6-50 ตัวอักษรที่ซ้ำติดกัน
140
+ ตั้งแต่ 4 ครั้งขึ้นไป (Repetition Collapse) — threshold ถูกตั้งให้
141
+ ไม่ False Positive กับโค้ดที่มี Pattern ซ้ำแบบปกติ (เช่น Code Block
142
+ ตัวอย่าง 2 อันที่คล้ายกัน) เพราะต้องซ้ำ 4 ครั้งขึ้นไปถึงจะ Flag
143
+ """
144
+ return bool(_REPETITION_LOOP_REGEX.search(text))
145
+
146
+
147
+ # ======================================================================
148
+ # 1) WEIGHTED ROUTER
149
+ # ======================================================================
150
+
151
+ @dataclass
152
+ class ExpertKeywords:
153
+ name: str
154
+ keywords: list = field(default_factory=list)
155
+ weight_multiplier: float = 1.0
156
+
157
+
158
+ EXPERT_REGISTRY = [
159
+ ExpertKeywords("python", ["python", "ไพธอน", "def ", ".py", "pip", "pandas", "numpy"]),
160
+ ExpertKeywords("typescript", ["typescript", ".ts", "interface", "npm", "const ", "javascript", "react", "node.js"]),
161
+ ExpertKeywords("reasoning", [
162
+ "คำนวณ", "อธิบายทีละขั้นตอน", "วิเคราะห์เหตุผล",
163
+ "โจทย์คณิตศาสตร์", "แก้สมการ", "หาค่า", "step by step"
164
+ ]),
165
+ ]
166
+
167
+ CODE_DEBUG_SIGNALS = ["โค้ด", "code", "error", "บัค", "bug", "พัง", "ฟังก์ชัน", "function"]
168
+ OUTPUT_SIGNAL_WORDS = ["output เป็น", "แปลงเป็น", "เขียนเป็น", "ทำเป็น", "output as", "convert to"]
169
+ AMBIGUOUS_THRESHOLD = 0.15
170
+
171
+ GREETING_SAFE_SUBSTRING_WORDS = ["สวัสดี", "ขอบคุณ", "หวัดดี", "ลาก่อน"]
172
+ GREETING_EXACT_MATCH_WORDS = ["บาย", "bye", "บ๊ายบาย"]
173
+
174
+
175
+ def _has_greeting_or_closing(query: str) -> bool:
176
+ if any(w in query for w in GREETING_SAFE_SUBSTRING_WORDS):
177
+ return True
178
+ normalized = re.sub(r'[^\w\u0E00-\u0E7F]', '', query).strip().lower()
179
+ return normalized in GREETING_EXACT_MATCH_WORDS
180
+
181
+
182
+ def _keyword_score(query: str, expert: "ExpertKeywords") -> float:
183
+ q_lower = query.lower()
184
+ score = 0.0
185
+ for kw in expert.keywords:
186
+ count = q_lower.count(kw.lower())
187
+ if count > 0:
188
+ position = q_lower.find(kw.lower())
189
+ position_boost = 1.0 + max(0, (30 - position) / 30) * 0.3
190
+ score += count * position_boost
191
+ return score * expert.weight_multiplier
192
+
193
+
194
+ def route_query(query: str, previous_expert: Optional[str] = None) -> dict:
195
+ """
196
+ previous_expert: Expert ที่ใช้ใน Turn ก่อนหน้า (สำหรับ Multi-turn Conversation)
197
+ ถ้าไม่ส่งมา (None) ทำงานแบบ Stateless เหมือนเดิม — Backward Compatible 100%
198
+ """
199
+ q_lower = query.lower()
200
+ has_debug_signal = any(sig in q_lower for sig in CODE_DEBUG_SIGNALS)
201
+ has_output_signal = any(sig in q_lower for sig in OUTPUT_SIGNAL_WORDS)
202
+ has_greeting = _has_greeting_or_closing(query)
203
+
204
+ scores = {e.name: _keyword_score(query, e) for e in EXPERT_REGISTRY}
205
+ sorted_scores = sorted(scores.items(), key=lambda x: x[1], reverse=True)
206
+
207
+ top_expert, top_score = sorted_scores[0]
208
+ second_expert, second_score = sorted_scores[1] if len(sorted_scores) > 1 else (None, 0.0)
209
+
210
+ lang_score = scores.get("python", 0.0) + scores.get("typescript", 0.0)
211
+
212
+ if has_debug_signal and lang_score == 0.0:
213
+ if previous_expert in ("python", "typescript") and not has_greeting:
214
+ return {
215
+ "expert": previous_expert,
216
+ "method": "history_continuation_debug",
217
+ "scores": scores,
218
+ "is_ambiguous": False,
219
+ }
220
+ return {"expert": None, "method": "none", "scores": scores, "is_ambiguous": True}
221
+
222
+ if top_score == 0.0:
223
+ if previous_expert is not None and not has_greeting:
224
+ return {
225
+ "expert": previous_expert,
226
+ "method": "history_continuation",
227
+ "scores": scores,
228
+ "is_ambiguous": False,
229
+ }
230
+ return {"expert": None, "method": "none", "scores": scores, "is_ambiguous": True}
231
+
232
+ diff_ratio = (top_score - second_score) / max(top_score, 1e-6)
233
+ is_ambiguous = diff_ratio < AMBIGUOUS_THRESHOLD
234
+
235
+ if has_output_signal:
236
+ is_ambiguous = True
237
+
238
+ return {
239
+ "expert": top_expert,
240
+ "method": "keyword_match_weighted",
241
+ "scores": scores,
242
+ "is_ambiguous": is_ambiguous,
243
+ }
244
+
245
+
246
+ # ======================================================================
247
+ # 2) LANGUAGE PURITY FILTER (Emoji-Aware)
248
+ # ======================================================================
249
+
250
+ ALLOWED_PATTERN = re.compile(
251
+ r'[^\u0E00-\u0E7F'
252
+ r'a-zA-Z0-9'
253
+ r'\s'
254
+ r'.,!?;:\'"()\[\]{}<>@#$%^&*_+=\-/\\|`~๐-๙×÷°≈≠±'
255
+ r'\U0001F1E6-\U0001F1FF'
256
+ r'\U0001F300-\U0001F5FF'
257
+ r'\U0001F600-\U0001F64F'
258
+ r'\U0001F680-\U0001F6FF'
259
+ r'\U0001F700-\U0001F77F'
260
+ r'\U0001F780-\U0001F7FF'
261
+ r'\U0001F800-\U0001F8FF'
262
+ r'\U0001F900-\U0001F9FF'
263
+ r'\U0001FA00-\U0001FA6F'
264
+ r'\U0001FA70-\U0001FAFF'
265
+ r'\u2600-\u26FF'
266
+ r'\u2700-\u27BF'
267
+ r'\uFE0F'
268
+ r'\u200D]'
269
+ )
270
+
271
+
272
+ def find_foreign_chars(text: str) -> list:
273
+ return ALLOWED_PATTERN.findall(text)
274
+
275
+
276
+ def detect_language_leakage_words(text: str) -> list:
277
+ words = re.findall(r'\S+', text)
278
+ suspicious_words = []
279
+ for word in words:
280
+ foreign = find_foreign_chars(word)
281
+ if foreign:
282
+ suspicious_words.append({"word": word, "foreign_chars": foreign})
283
+ return suspicious_words
284
+
285
+
286
+ def clean_or_flag_response(text: str, auto_retry_callback=None) -> dict:
287
+ suspicious = detect_language_leakage_words(text)
288
+
289
+ if not suspicious:
290
+ return {"text": text, "is_clean": True, "flagged_words": []}
291
+
292
+ print(f"[WARNING] พบคำที่อาจเป็น language leakage: {suspicious}")
293
+
294
+ if auto_retry_callback is not None:
295
+ print("[INFO] กำลัง retry เพื่อแก้ language leakage...")
296
+ new_text = auto_retry_callback()
297
+ recheck = detect_language_leakage_words(new_text)
298
+ if not recheck:
299
+ return {"text": new_text, "is_clean": True, "flagged_words": []}
300
+ print("[WARNING] Retry แล้วยังพบ leakage อยู่ — คืนคำตอบเดิมพร้อม flag")
301
+
302
+ return {"text": text, "is_clean": False, "flagged_words": suspicious}
303
+
304
+
305
+ # ======================================================================
306
+ # 3) HEDGE-PHRASE LIMITER
307
+ # ======================================================================
308
+
309
+ HEDGE_PHRASE_PATTERN = re.compile(r'ผม(?:คิด|เชื่อ|เข้าใจ)?ว่า')
310
+
311
+
312
+ def limit_hedge_phrases(text: str, max_allowed: int = 1) -> str:
313
+ matches = list(HEDGE_PHRASE_PATTERN.finditer(text))
314
+ if len(matches) <= max_allowed:
315
+ return text
316
+
317
+ result_parts = []
318
+ last_end = 0
319
+ count = 0
320
+ for m in matches:
321
+ if count < max_allowed:
322
+ result_parts.append(text[last_end:m.end()])
323
+ else:
324
+ result_parts.append(text[last_end:m.start()])
325
+ last_end = m.end()
326
+ count += 1
327
+ result_parts.append(text[last_end:])
328
+
329
+ cleaned = ''.join(result_parts)
330
+ cleaned = re.sub(r'\s{2,}', ' ', cleaned)
331
+ cleaned = re.sub(r'\s+([.,!?ครับค่ะ])', r'\1', cleaned)
332
+ cleaned = re.sub(r',\s*,', ',', cleaned)
333
+ return cleaned.strip()
334
+
335
+
336
+ # ======================================================================
337
+ # 4) REFUSAL DETECTORS
338
+ # ======================================================================
339
+
340
+ REFUSAL_PATTERNS = [
341
+ "ไม่สามารถแปล",
342
+ "ไม่สามารถที่จะแปล",
343
+ "ยากต่อการเข้าใจถ้าแปล",
344
+ "ขออภัยที่ไม่สามารถ",
345
+ "ยังไม่สามารถแปลเป็นภาษาไทยได้",
346
+ "ไม่ได้เป็นข้อความภาษาไทย",
347
+ "cannot translate",
348
+ ]
349
+
350
+
351
+ def is_refusal(text: str) -> bool:
352
+ return any(p in text for p in REFUSAL_PATTERNS)
353
+
354
+
355
+ CODE_REFUSAL_PATTERNS = [
356
+ "ไม่สามารถช่วยคุณเขียนโค้ด",
357
+ "ไม่สามารถเขียนโค้ด",
358
+ "ผมไม่สามารถเขียน",
359
+ "ลองเขียนเองนะ",
360
+ "ไม่สามารถช่วยเขียน",
361
+ "ขอแนะนำให้คุณลองเขียนเอง",
362
+ "ไม่สามารถสร้างโค้ด",
363
+ ]
364
+
365
+
366
+ def is_code_refusal(text: str) -> bool:
367
+ return any(p in text for p in CODE_REFUSAL_PATTERNS)
368
+
369
+
370
+ # ======================================================================
371
+ # 5) PREAMBLE STRIPPER
372
+ # ======================================================================
373
+
374
+ _FALLBACK_PREAMBLE_PATTERNS = [
375
+ r"^here'?s\s+(the\s+)?translation.*?:\s*",
376
+ r"^translation\s*:\s*",
377
+ r"^คำแปล\s*:\s*",
378
+ r"^แปลว่า\s*:\s*",
379
+ ]
380
+ _FALLBACK_PREAMBLE_REGEX = re.compile("|".join(_FALLBACK_PREAMBLE_PATTERNS), flags=re.IGNORECASE)
381
+
382
+
383
+ def _find_first_thai_char_index(text: str) -> Optional[int]:
384
+ for i, ch in enumerate(text):
385
+ if '\u0E00' <= ch <= '\u0E7F':
386
+ return i
387
+ return None
388
+
389
+
390
+ def strip_translation_preamble(text: str) -> str:
391
+ text = text.strip()
392
+ first_thai_idx = _find_first_thai_char_index(text)
393
+
394
+ if first_thai_idx is not None and first_thai_idx > 0:
395
+ preamble = text[:first_thai_idx]
396
+ if re.search(r'translat', preamble, re.IGNORECASE):
397
+ remainder = text[first_thai_idx:]
398
+ remainder = re.sub(r'^[\s\-:>*]+', '', remainder)
399
+ return remainder.strip()
400
+
401
+ stripped = _FALLBACK_PREAMBLE_REGEX.sub("", text, count=1)
402
+ return stripped.strip()
403
+
404
+
405
+ # ======================================================================
406
+ # 6) PERSONA ENFORCER
407
+ # ======================================================================
408
+
409
+ def enforce_persona(text: str) -> str:
410
+ text = text.replace("ดิฉัน", "ผม")
411
+ text = text.replace("ฉัน", "ผม")
412
+ text = text.replace("ค่ะ", "ครับ")
413
+ return text
414
+
415
+
416
+ # ======================================================================
417
+ # 7) PER-EXPERT CONFIG
418
+ # ======================================================================
419
+
420
+ @dataclass
421
+ class ExpertConfig:
422
+ name: str
423
+ max_tokens: int = 512
424
+ temp: float = 0.7
425
+ top_p: float = 0.9
426
+
427
+
428
+ EXPERT_CONFIGS = {
429
+ "python": ExpertConfig(name="python", max_tokens=800, temp=0.3, top_p=0.9),
430
+ "typescript": ExpertConfig(name="typescript", max_tokens=800, temp=0.3, top_p=0.9),
431
+ "reasoning": ExpertConfig(name="reasoning", max_tokens=1000, temp=0.6, top_p=0.9),
432
+ }
433
+
434
+
435
+ def get_config(expert_name: str) -> ExpertConfig:
436
+ return EXPERT_CONFIGS.get(expert_name, ExpertConfig(name=expert_name))
437
+
438
+
439
+ # ======================================================================
440
+ # 8) PERSONA CONSISTENCY (System Prompt Injection)
441
+ # ======================================================================
442
+
443
+ PERSONA_MALE_SUFFIX = (
444
+ "\n\nกฎการตอบ: ให้ใช้คำลงท้าย ครับ และแทนตัวเองว่า ผม "
445
+ "อย่างสม่ำเสมอตลอดคำตอบ ห้ามใช้ ค่ะ หรือ ฉัน เด็ดขาด"
446
+ )
447
+
448
+
449
+ def inject_persona(system_prompt: str = "") -> str:
450
+ return system_prompt + PERSONA_MALE_SUFFIX
451
+
452
+
453
+ # ======================================================================
454
+ # SELF-TEST
455
+ # ======================================================================
456
+
457
+ if __name__ == "__main__":
458
+ print("=" * 70)
459
+ print("TEST: is_degenerate_query (Round 7 — Fast-Path Guard)")
460
+ print("=" * 70)
461
+ degenerate_cases = ["", " ", "\n\n\n", "🐍🐍🐍", "???!!!...", "?", "{{}} }}"]
462
+ for q in degenerate_cases:
463
+ result = is_degenerate_query(q)
464
+ print(f"Query: {q!r} -> is_degenerate={result}")
465
+ assert result is True, f"❌ FAIL: {q!r} ควรถูก Flag เป็น Degenerate!"
466
+ print("✅ PASS: Query ว่าง/Emoji-only/Symbol-only ถูก Flag ถูกต้องหมด\n")
467
+
468
+ meaningful_cases = ["help", "ทำไง", "def foo(): pass", "เขียน Python function ที่บวกเลขสองตัว"]
469
+ for q in meaningful_cases:
470
+ result = is_degenerate_query(q)
471
+ print(f"Query: {q!r} -> is_degenerate={result}")
472
+ assert result is False, f"❌ FAIL: {q!r} มีเนื้อหาจริง ไม่ควรถูก Flag!"
473
+ print("✅ PASS: Query ที่มีเนื้อหาจริงไม่ถูก Flag ผิดเลย\n")
474
+
475
+ print("=" * 70)
476
+ print("TEST: get_greeting_quick_reply (Round 8)")
477
+ print("=" * 70)
478
+ greeting_true_cases = ["สวัสดี", "สวัสดีครับ", "หวัดดี", "hi", "hello"]
479
+ for q in greeting_true_cases:
480
+ result = get_greeting_quick_reply(q)
481
+ assert result == GREETING_QUICK_REPLY_TEXT, f"❌ FAIL: {q!r} ควรได้ Greeting Reply!"
482
+ print("✅ PASS: Greeting คำล้วนๆ ได้ Quick-Reply ถูกต้อง")
483
+
484
+ closing_true_cases = ["บาย", "bye", "ลาก่อน"]
485
+ for q in closing_true_cases:
486
+ result = get_greeting_quick_reply(q)
487
+ assert result == CLOSING_QUICK_REPLY_TEXT, f"❌ FAIL: {q!r} ควรได้ Closing Reply!"
488
+ print("✅ PASS: Closing คำล้วนๆ ได้ Quick-Reply ถูกต้อง")
489
+
490
+ thanks_true_cases = ["ขอบคุณ", "ขอบคุณครับ", "ขอบคุณมากครับ", "thanks"]
491
+ for q in thanks_true_cases:
492
+ result = get_greeting_quick_reply(q)
493
+ assert result == THANKS_QUICK_REPLY_TEXT, f"❌ FAIL: {q!r} ควรได้ Thanks Reply!"
494
+ print("✅ PASS: Thanks คำล้วนๆ ได้ Quick-Reply ถูกต้อง")
495
+
496
+ false_positive_cases = [
497
+ "อธิบายเพิ่มอีกหน่อย",
498
+ "คุณช่วยอะไรได้บ้าง",
499
+ "ขอบคุณสำหรับ function นี้นะครับ",
500
+ "แก้ให้หน่อย",
501
+ ]
502
+ for q in false_positive_cases:
503
+ result = get_greeting_quick_reply(q)
504
+ assert result is None, f"❌ FAIL: {q!r} มีเนื้อหาจริง ไม่ควรถูก Quick-Reply!"
505
+ print("✅ PASS: Query ที่มีเนื้อหาจริงแซมอยู่ไม่ถูก Quick-Reply ผิดเลย\n")
506
+
507
+ print("=" * 70)
508
+ print("TEST: is_repetitive_response (Round 9)")
509
+ print("=" * 70)
510
+ looped_text = "ผมขออภัยที่ทำให้คุณสับสน " * 15
511
+ assert is_repetitive_response(looped_text) is True, "❌ FAIL: ควรจับ Loop ได้!"
512
+ print("✅ PASS: จับ Repetition Loop ภาษาไทยได้ถูกต้อง")
513
+
514
+ looped_list = "ดูข้อมูลจาก Google Maps, " * 20
515
+ assert is_repetitive_response(looped_list) is True, "❌ FAIL: ควรจับ Loop แบบ List ได้!"
516
+ print("✅ PASS: จับ Repetition Loop แบบรายการซ้ำได้ถูกต้อง")
517
+
518
+ normal_code_response = (
519
+ "แน่นอนครับ นี่คือฟังก์ชันที่เพิ่ม docstring:\n\n"
520
+ "```python\ndef add_numbers(a, b):\n"
521
+ ' """บวกเลขสองตัวเข้าด้วยกัน"""\n'
522
+ " return a + b\n```\n\n"
523
+ "ใน docstring นี้ ผมอธิบายว่าฟังก์ชันนี้จะบวกเลขสองตัวเข้าด้วยกัน "
524
+ "และระบุประเภทของพารามิเตอร์ที่ใช้ครับ"
525
+ )
526
+ assert is_repetitive_response(normal_code_response) is False, "❌ FAIL: คำตอบปกติไม่ควรถูก Flag!"
527
+ print("✅ PASS: คำตอบปกติที่มีโค้ดซ้ำเล็กน้อยไม่ถูก Flag ผิด\n")
528
+
529
+ print("=" * 70)
530
+ print("TEST: Weighted Router (Stateless)")
531
+ print("=" * 70)
532
+ for q in ["เขียน Python function ที่บวกเลขสองตัว", "def foo(): pass"]:
533
+ result = route_query(q)
534
+ print(f"Query: {q!r} -> {result}\n")
535
+
536
+ print("=" * 70)
537
+ print("TEST: Router — History Continuation")
538
+ print("=" * 70)
539
+ r1 = route_query("แก้ให้หน่อย", previous_expert="python")
540
+ assert r1["expert"] == "python" and r1["method"] == "history_continuation"
541
+ print("✅ PASS: 'แก้ให้หน่อย' ใช้ previous_expert ต่อ\n")
542
+
543
+ r2 = route_query("อธิบายเพิ่มอีกหน่อย", previous_expert="python")
544
+ assert r2["expert"] == "python", "❌ FAIL: ควรใช้ previous_expert ต่อ!"
545
+ print("✅ PASS: 'อธิบายเพิ่มอีกหน่อย' ไม่ถูกเข้าใจผิดว่าเป็นการลาก่อนแล้ว\n")
546
+
547
+ r2b = route_query("อธิบายว่าแก้ตรงไหน", previous_expert="python")
548
+ assert r2b["expert"] == "python", "❌ FAIL: Bug 'บาย' ใน 'อธิบาย' กลับมาอีกแล้ว!"
549
+ print("✅ PASS: 'อธิบายว่าแก้ตรงไหน' ไม่ถูก Flag ผิดเป็น Greeting แล้ว\n")
550
+
551
+ r3 = route_query("แก้ให้หน่อย", previous_expert=None)
552
+ assert r3["expert"] is None
553
+ print("✅ PASS: ไม่มี previous_expert ยังทำงานแบบเดิม\n")
554
+
555
+ r4 = route_query("ขอบคุณครับ", previous_expert="typescript")
556
+ assert r4["expert"] is None, "❌ FAIL: คำขอบคุณจริงๆต้องยัง Flag เป็น Greeting!"
557
+ print("✅ PASS: 'ขอบคุณครับ' ยังถูก Flag เป็น Greeting ถูกต้อง\n")
558
+
559
+ r5 = route_query("บาย", previous_expert="python")
560
+ assert r5["expert"] is None, "❌ FAIL: คำว่า 'บาย' โดดๆต้องยัง Flag เป็น Greeting!"
561
+ print("✅ PASS: 'บาย' คำเดียวโดดๆ ยังถูก Flag เป็น Greeting ถูกต้อง\n")
562
+
563
+ print("=" * 70)
564
+ print("TEST: is_code_refusal")
565
+ print("=" * 70)
566
+ assert is_code_refusal("ขออภัยครับ ผมไม่สามารถช่วยคุณเขียนโค้ด Python ที่บวกเลขสองตัวได้ครับ") is True
567
+ assert is_code_refusal("```python\ndef add(a, b):\n return a + b\n```\nครับ") is False
568
+ print("✅ PASS: Code Refusal Detector ทำงานถูกต้อง\n")
569
+
570
+ print("=" * 70)
571
+ print("TEST: Language purity filter — Emoji")
572
+ print("=" * 70)
573
+ result_emoji = clean_or_flag_response("ผมไม่สามารถทำให้🐍🐍🐍เป็นคำว่า python ได้ครับ 😊👍")
574
+ assert result_emoji["is_clean"] is True
575
+ print("✅ PASS: Emoji ไม่ถูก Flag ผิด\n")
576
+
577
+ print("=" * 70)
578
+ print("TEST: Persona enforcer")
579
+ print("=" * 70)
580
+ fixed = enforce_persona("ขออภัยค่ะ ฉันไม่เข้าใจคำถามนี้ค่ะ")
581
+ assert "ค่ะ" not in fixed and "ฉัน" not in fixed
582
+ print("✅ PASS\n")
583
+
584
+ print("=" * 70)
585
+ print("🎉 SELF-TEST ทั้งหมดผ่านเรียบร้อย")
586
+ print("=" * 70)