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

v2: Add streaming support + reasoning expert routing

Browse files
Files changed (1) hide show
  1. moe_orchestrator.py +505 -0
moe_orchestrator.py ADDED
@@ -0,0 +1,505 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import time
3
+ from datetime import datetime
4
+ from typing import Optional
5
+ from mlx_lm import load, generate, stream_generate
6
+ from mlx_lm.sample_utils import make_sampler, make_logits_processors
7
+
8
+ from moe_fixes import (
9
+ route_query, get_config, clean_or_flag_response, inject_persona,
10
+ limit_hedge_phrases, is_refusal, is_code_refusal, strip_translation_preamble,
11
+ detect_language_leakage_words, enforce_persona,
12
+ is_degenerate_query, DEGENERATE_FALLBACK_RESPONSE, get_greeting_quick_reply,
13
+ is_repetitive_response
14
+ )
15
+
16
+ # หมายเหตุสำคัญ: ไม่เปิด fix_mistral_regex เพราะพิสูจน์แล้วในโปรเจกต์นี้ว่า
17
+ # ทำให้เกิด Train-Inference Tokenizer Mismatch — LoRA Adapter จะตอบผิดเพี้ยน
18
+
19
+
20
+ # Repetition Penalty (Round 9): ป้องกัน Generation Collapse ที่พบใน
21
+ # Multi-turn Test ทั้ง Python Expert และ Reasoning Expert — ค่า 1.3 เป็น
22
+ # ค่ากลางที่ลด Loop ได้โดยไม่กระทบความลื่นไหลของภาษาไทยมากเกินไป
23
+ REPETITION_PENALTY = 1.15
24
+ REPETITION_CONTEXT_SIZE = 20
25
+
26
+
27
+ class MoEOrchestrator:
28
+ MAX_HISTORY_MESSAGES = 6
29
+
30
+ def __init__(self, base_model_path: str, adapter_paths: dict, log_file: str = "route_log.txt"):
31
+ self.log_file = log_file
32
+
33
+ print("[INFO] Loading base model (no adapter, ใช้สำหรับ Translation + Classification)...")
34
+ self.base_model, self.tokenizer = load(base_model_path)
35
+
36
+ self.experts = {}
37
+ for name, path in adapter_paths.items():
38
+ print(f"[INFO] Loading Expert: {name} ...")
39
+ model, _ = load(base_model_path, adapter_path=path)
40
+ self.experts[name] = model
41
+ print("[INFO] All experts loaded and cached in memory. Ready.\n")
42
+
43
+ def format_prompt(self, user_content: str, use_persona: bool = True, history: Optional[list] = None) -> str:
44
+ messages = []
45
+ if use_persona:
46
+ messages.append({"role": "system", "content": inject_persona("")})
47
+
48
+ if history:
49
+ trimmed_history = history[-self.MAX_HISTORY_MESSAGES:]
50
+ messages.extend(trimmed_history)
51
+
52
+ messages.append({"role": "user", "content": user_content})
53
+ return self.tokenizer.apply_chat_template(
54
+ messages,
55
+ tokenize=False,
56
+ add_generation_prompt=True
57
+ )
58
+
59
+ def check_truncation(self, text: str, max_tokens: int) -> bool:
60
+ token_count = len(self.tokenizer.encode(text))
61
+ return token_count >= max_tokens - 5
62
+
63
+ def llm_classify_route(self, query: str, fallback_expert: str = "reasoning") -> str:
64
+ classify_content = (
65
+ "Classify the following user query into exactly ONE category. "
66
+ "Reply with ONLY one word: python, typescript, or reasoning.\n\n"
67
+ f'Query: "{query}"\n\n'
68
+ "Category:"
69
+ )
70
+ prompt = self.format_prompt(classify_content, use_persona=False)
71
+ classify_sampler = make_sampler(temp=0.1, top_p=0.9)
72
+ raw_result = generate(
73
+ self.base_model, self.tokenizer, prompt=prompt,
74
+ max_tokens=10, sampler=classify_sampler
75
+ )
76
+ result = raw_result.strip().lower()
77
+ print(f"[CLASSIFIER-DEBUG] raw output: {raw_result!r}")
78
+
79
+ if "python" in result:
80
+ return "python"
81
+ elif "typescript" in result:
82
+ return "typescript"
83
+ elif "reasoning" in result:
84
+ return "reasoning"
85
+ else:
86
+ print(f"[WARNING] ⚠️ Classifier ตอบไม่ตรงหมวดใดเลย ('{result}') → ใช้ Weighted Score Fallback: {fallback_expert}")
87
+ return fallback_expert
88
+
89
+ def route(self, query: str, previous_expert: Optional[str] = None) -> tuple:
90
+ result = route_query(query, previous_expert=previous_expert)
91
+ expert = result["expert"]
92
+ method = result["method"]
93
+ scores = result["scores"]
94
+
95
+ print(f"[ROUTER-DEBUG] scores={scores} ambiguous={result['is_ambiguous']} method={method}")
96
+
97
+ if method in ("history_continuation", "history_continuation_debug"):
98
+ print(f"[INFO] Query ไม่มี Keyword ชัดเจน แต่มี previous_expert='{previous_expert}' → ใช้ต่อทันที (ข้าม Classifier)")
99
+ return expert, method
100
+
101
+ if expert is None or result["is_ambiguous"]:
102
+ code_scores = {"python": scores.get("python", 0.0), "typescript": scores.get("typescript", 0.0)}
103
+ has_code_signal = any(v > 0 for v in code_scores.values())
104
+ reasoning_score = scores.get("reasoning", 0.0)
105
+
106
+ if has_code_signal and reasoning_score == 0.0:
107
+ expert = max(code_scores, key=code_scores.get)
108
+ method = "weighted_tiebreak_code_only"
109
+ print(f"[INFO] ความกำกวมเป็นแค่ระหว่าง python/typescript (reasoning=0) → ใช้ Weighted Score ตัดสินตรงๆ: {expert}")
110
+ return expert, method
111
+
112
+ if any(v > 0 for v in scores.values()):
113
+ weighted_fallback = max(scores, key=scores.get)
114
+ else:
115
+ weighted_fallback = previous_expert if previous_expert else "reasoning"
116
+
117
+ print("[INFO] Weighted router ตัดสินใจไม่ได้จริง → ใช้ LLM Classifier ช่วย...")
118
+ expert = self.llm_classify_route(query, fallback_expert=weighted_fallback)
119
+ method = "llm_classifier"
120
+
121
+ return expert, method
122
+
123
+ def extract_code_blocks(self, text: str):
124
+ text = re.sub(r'\be\.g\.\b', 'for example', text, flags=re.IGNORECASE)
125
+ text = re.sub(r'\bi\.e\.\b', 'that is', text, flags=re.IGNORECASE)
126
+
127
+ code_blocks = re.findall(r"```[\s\S]*?```", text)
128
+ placeholder_text = text
129
+ for i, block in enumerate(code_blocks):
130
+ placeholder_text = placeholder_text.replace(block, f"[[CODE_BLOCK_{i}]]", 1)
131
+ return placeholder_text, code_blocks
132
+
133
+ def restore_code_blocks(self, translated_text: str, code_blocks: list):
134
+ for i, block in enumerate(code_blocks):
135
+ translated_text = translated_text.replace(f"[[CODE_BLOCK_{i}]]", block)
136
+ return translated_text
137
+
138
+ def _run_translation(self, text_only: str, max_tokens: int, minimal: bool = False) -> str:
139
+ if minimal:
140
+ translation_content = f"Translate this text into Thai:\n\n{text_only}"
141
+ else:
142
+ translation_content = (
143
+ "Translate the following text to Thai naturally.\n"
144
+ "Use 'ผม' as the first-person pronoun (never 'ฉัน' or 'ดิฉัน'). "
145
+ "End sentences with 'ครับ' where appropriate.\n"
146
+ "Keep [[CODE_BLOCK_N]] placeholders and all numbers unchanged.\n"
147
+ "Output ONLY the translated text, no extra commentary:\n\n"
148
+ f"{text_only}"
149
+ )
150
+ prompt = self.format_prompt(translation_content, use_persona=False)
151
+ translate_sampler = make_sampler(temp=0.3, top_p=0.9)
152
+ return generate(
153
+ self.base_model, self.tokenizer, prompt=prompt,
154
+ max_tokens=max_tokens, sampler=translate_sampler
155
+ )
156
+
157
+ def _has_translation_problem(self, text: str) -> bool:
158
+ if is_refusal(text):
159
+ return True
160
+ if detect_language_leakage_words(text):
161
+ return True
162
+ return False
163
+
164
+ def translate_to_thai(self, text: str, max_tokens: int = 800) -> tuple:
165
+ text_only, code_blocks = self.extract_code_blocks(text)
166
+
167
+ thai_text = self._run_translation(text_only, max_tokens, minimal=False)
168
+
169
+ if self._has_translation_problem(thai_text):
170
+ problem_reason = "Refusal" if is_refusal(thai_text) else "Language Leakage"
171
+ print(f"[WARNING] ⚠️ พบปัญหาการแปล ({problem_reason}) — Retry ครั้งเดียว (จำกัดไม่ให้ Retry ซ้ำ)...")
172
+
173
+ thai_text = self._run_translation(text_only, max_tokens, minimal=True)
174
+ thai_text = strip_translation_preamble(thai_text)
175
+
176
+ if self._has_translation_problem(thai_text):
177
+ print("[WARNING] ⚠️ Retry แล้วยังมีปัญหาอยู่ — ยอมรับผลลัพธ์นี้ไปก่อน (ไม่ Retry ซ้ำเพื่อคุม Latency)")
178
+
179
+ truncated = self.check_truncation(thai_text, max_tokens)
180
+
181
+ thai_text = limit_hedge_phrases(thai_text, max_allowed=1)
182
+
183
+ restored_text = self.restore_code_blocks(thai_text, code_blocks)
184
+
185
+ restored_text = enforce_persona(restored_text)
186
+
187
+ purity = clean_or_flag_response(restored_text)
188
+ if not purity["is_clean"]:
189
+ print(f"[WARNING] ⚠️ พบคำที่ไม่ใช่ภาษาไทยหลุดมาในคำแปล (ยอมรับผลลัพธ์นี้ไปพร้อม flag): {purity['flagged_words']}")
190
+
191
+ return purity["text"], truncated
192
+
193
+ def _generate_code_response(self, expert_model, prompt: str, config, effective_max_tokens: int) -> tuple:
194
+ logits_processors = make_logits_processors(
195
+ repetition_penalty=REPETITION_PENALTY,
196
+ repetition_context_size=REPETITION_CONTEXT_SIZE,
197
+ )
198
+ sampler = make_sampler(temp=config.temp, top_p=config.top_p)
199
+ response = generate(
200
+ expert_model, self.tokenizer, prompt=prompt,
201
+ max_tokens=effective_max_tokens,
202
+ sampler=sampler,
203
+ logits_processors=logits_processors,
204
+ )
205
+
206
+ has_refusal = is_code_refusal(response)
207
+ has_loop = is_repetitive_response(response)
208
+
209
+ if has_refusal or has_loop:
210
+ reason = "ปฏิเสธงานโดยไม่มีเหตุผลสมควร" if has_refusal else "ติด Repetition Loop (Generate ซ้ำวนไม่รู้จบ)"
211
+ print(f"[WARNING] ⚠️ Code Expert {reason} — Retry ครั้งเดียว (ป้องกัน Compounding Bias ลามไป History)...")
212
+ retry_sampler = make_sampler(temp=min(config.temp + 0.2, 1.0), top_p=config.top_p)
213
+ retry_logits_processors = make_logits_processors(
214
+ repetition_penalty=REPETITION_PENALTY,
215
+ repetition_context_size=REPETITION_CONTEXT_SIZE,
216
+ )
217
+ retry_response = generate(
218
+ expert_model, self.tokenizer, prompt=prompt,
219
+ max_tokens=effective_max_tokens,
220
+ sampler=retry_sampler,
221
+ logits_processors=retry_logits_processors,
222
+ )
223
+ if not (is_code_refusal(retry_response) or is_repetitive_response(retry_response)):
224
+ return retry_response, True
225
+ print("[WARNING] ⚠️ Retry แล้วยังมีปัญหาอยู่ — ยอมรับผลลัพธ์เดิมไปก่อน (ไม่ Retry ซ้ำเพื่อคุม Latency)")
226
+ return response, True
227
+
228
+ return response, False
229
+
230
+ # ---------- Streaming Support (Round 12: Stream Output) ----------
231
+ # หมายเหตุ: ใช้เฉพาะกับ run_interactive() ผ่าน query_stream() เท่านั้น
232
+ # query() เดิมด้านล่างไม่ถูกแก้ไข เพื่อไม่กระทบ test_multiturn.py
233
+ # ที่ต้องใช้ Return Value เป็น String เต็มก้อนสำหรับ Automated Check
234
+
235
+ def _generate_code_response_stream(self, expert_model, prompt: str, config, effective_max_tokens: int):
236
+ """
237
+ Streaming version ของ _generate_code_response()
238
+ ใช้ Sliding Window Check ทุก ~50 Token แทนการตรวจทั้งก้อนหลัง Generate ครบ
239
+ เพราะ Streaming ทำให้ "ย้อนกลับไปแก้สิ่งที่ User เห็นไปแล้ว" ไม่ได้
240
+ ยอมรับ Trade-off ว่า User อาจเห็นการซ้ำ 1-2 รอบก่อนถูกตัด
241
+ (ดีกว่าเห็นซ้ำจนครบ max_tokens แบบเดิม)
242
+ """
243
+ logits_processors = make_logits_processors(
244
+ repetition_penalty=REPETITION_PENALTY,
245
+ repetition_context_size=REPETITION_CONTEXT_SIZE,
246
+ )
247
+ sampler = make_sampler(temp=config.temp, top_p=config.top_p)
248
+
249
+ pending = "" # Buffer กัน enforce_persona() ตัดคำครึ่งที่ Boundary
250
+ check_window = ""
251
+ tokens_since_check = 0
252
+ CHECK_EVERY_N_TOKENS = 50
253
+ aborted = False
254
+
255
+ for gen_response in stream_generate(
256
+ expert_model, self.tokenizer, prompt=prompt,
257
+ max_tokens=effective_max_tokens,
258
+ sampler=sampler,
259
+ logits_processors=logits_processors,
260
+ ):
261
+ delta = gen_response.text
262
+ pending += delta
263
+ check_window += delta
264
+ tokens_since_check += 1
265
+
266
+ # กันคำถูกตัดครึ่ง: เก็บ 10 ตัวท้ายสุดไว้ ค่อย Flush ส่วนที่ปลอดภัย
267
+ if len(pending) > 10:
268
+ safe_part = pending[:-10]
269
+ pending = pending[-10:]
270
+ yield enforce_persona(safe_part)
271
+
272
+ # Sliding Window Repetition Check ทุก ~50 Token
273
+ if tokens_since_check >= CHECK_EVERY_N_TOKENS:
274
+ if is_repetitive_response(check_window):
275
+ print("\n[WARNING] ⚠️ ตรวจพบ Repetition Loop กลาง Stream — ตัดจบทันที")
276
+ aborted = True
277
+ break
278
+ check_window = ""
279
+ tokens_since_check = 0
280
+
281
+ if pending:
282
+ yield enforce_persona(pending)
283
+
284
+ if aborted:
285
+ yield "\n\n[ตัดคำตอบเพื่อป้องกันการวนซ้ำ — ขออภัยในความไม่สะดวกครับ]"
286
+
287
+ self._last_stream_had_loop = aborted
288
+
289
+ def query_stream(self, user_query: str, max_tokens: Optional[int] = None,
290
+ history: Optional[list] = None, previous_expert: Optional[str] = None):
291
+ """
292
+ เวอร์ชัน Streaming ของ query() — ใช้เฉพาะ run_interactive()
293
+ ไม่แทนที่ query() เดิม เพื่อไม่กระทบ test_multiturn.py ที่ต้องใช้ Return
294
+ เ��็น String เต็มก้อนสำหรับ Automated Check
295
+
296
+ Yields: str (Chunk ของข้อความ) — หลัง Stream จบ self._last_expert_used
297
+ จะถูกตั้งค่าเหมือน query() ทุกประการ
298
+ """
299
+ start_time = time.time()
300
+
301
+ if is_degenerate_query(user_query):
302
+ latency = time.time() - start_time
303
+ self.log_interaction(user_query, "none", "degenerate_fast_path", latency, False)
304
+ print(f"[INFO] ⚡ Fast-Path: Query ไม่มีเนื้อหาที่มีความหมาย → ตอบทันทีโดยไม่ผ่าน Model (Latency: {latency:.4f}s)")
305
+ self._last_expert_used = previous_expert
306
+ yield DEGENERATE_FALLBACK_RESPONSE
307
+ return
308
+
309
+ greeting_reply = get_greeting_quick_reply(user_query)
310
+ if greeting_reply is not None:
311
+ latency = time.time() - start_time
312
+ self.log_interaction(user_query, "none", "greeting_quick_reply", latency, False)
313
+ print(f"[INFO] ⚡ Quick-Reply: Greeting/Closing ล้วนๆ → ตอบทันทีโดยไม่ผ่าน Model (Latency: {latency:.4f}s)")
314
+ self._last_expert_used = previous_expert
315
+ yield greeting_reply
316
+ return
317
+
318
+ expert_name, method = self.route(user_query, previous_expert=previous_expert)
319
+ config = get_config(expert_name)
320
+ effective_max_tokens = max_tokens if max_tokens is not None else config.max_tokens
321
+
322
+ print(f"[ROUTER] เลือก Expert: {expert_name} (วิธี: {method}) | max_tokens={effective_max_tokens}")
323
+
324
+ expert_model = self.experts[expert_name]
325
+ prompt = self.format_prompt(user_query, history=history)
326
+
327
+ if expert_name == "reasoning":
328
+ # Reasoning ต้อง Translate หลัง Generate เสมอ — Stream ตรงๆจะโชว์
329
+ # ข้อความที่ยังไม่ใช่คำตอบสุดท้าย จึง Fallback ไปใช้ query() แบบ
330
+ # Blocking แล้ว Return ทั้งก้อนเป็น Chunk เดียว
331
+ final_response = self.query(
332
+ user_query, max_tokens=max_tokens,
333
+ history=history, previous_expert=previous_expert
334
+ )
335
+ yield final_response
336
+ return
337
+
338
+ try:
339
+ for chunk in self._generate_code_response_stream(
340
+ expert_model, prompt, config, effective_max_tokens
341
+ ):
342
+ yield chunk
343
+ except Exception as e:
344
+ yield f"\n[ERROR] เกิดข้อผิดพลาดระหว่าง Generate: {str(e)}"
345
+ self._last_expert_used = expert_name
346
+ return
347
+
348
+ latency = time.time() - start_time
349
+ self.log_interaction(user_query, expert_name, method, latency, False)
350
+ print(f"\n[INFO] ใช้เวลา: {latency:.2f} วินาที")
351
+ self._last_expert_used = expert_name
352
+
353
+ def log_interaction(self, query: str, expert: str, method: str, latency: float, truncated: bool):
354
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
355
+ flag = " | ⚠️TRUNCATED" if truncated else ""
356
+ log_line = f"[{timestamp}] Expert={expert} | Method={method} | Latency={latency:.2f}s{flag} | Query={query}\n"
357
+ with open(self.log_file, "a", encoding="utf-8") as f:
358
+ f.write(log_line)
359
+
360
+ # ---------- Main Entry Point (Round 7: เพิ่ม Fast-Path Guard) ----------
361
+ def query(self, user_query: str, max_tokens: Optional[int] = None,
362
+ history: Optional[list] = None, previous_expert: Optional[str] = None) -> str:
363
+ """
364
+ history: List ของ {"role": "user"/"assistant", "content": str} จาก Turn ก่อนหน้า
365
+ previous_expert: ชื่อ Expert ที่ใช้ใน Turn ก่อนหน้า
366
+ Fast-Path Guard (Round 7): ถ้า user_query ไม่มีตัวอักษรที่มีความหมาย
367
+ เพียงพอ (ว่าง/Emoji-only/Symbol-only) จะตอบด้วย Canned Response ทันที
368
+ โดยไม่แตะ Model เลย — ป้องกัน Hallucination และ Latency ที่ไม่จำเป็น
369
+ (พบจาก Adversarial Test ว่า Query ประเภทนี้กิน Latency 4-32 วินาที
370
+ และเสี่ยงหลุดคำตอบเพี้ยน เช่น พูดถึง "crab" ที่ไม่เกี่ยวข้อง)
371
+ """
372
+ start_time = time.time()
373
+
374
+ if is_degenerate_query(user_query):
375
+ latency = time.time() - start_time
376
+ self.log_interaction(user_query, "none", "degenerate_fast_path", latency, False)
377
+ print(f"[INFO] ⚡ Fast-Path: Query ไ���่มีเนื้อหาที่มีความหมาย → ตอบทันทีโดยไม่ผ่าน Model (Latency: {latency:.4f}s)")
378
+ self._last_expert_used = previous_expert
379
+ return DEGENERATE_FALLBACK_RESPONSE
380
+
381
+ greeting_reply = get_greeting_quick_reply(user_query)
382
+ if greeting_reply is not None:
383
+ latency = time.time() - start_time
384
+ self.log_interaction(user_query, "none", "greeting_quick_reply", latency, False)
385
+ print(f"[INFO] ⚡ Quick-Reply: Greeting/Closing ล้วนๆ → ตอบทันทีโดยไม่ผ่าน Model (Latency: {latency:.4f}s)")
386
+ self._last_expert_used = previous_expert
387
+ return greeting_reply
388
+
389
+ expert_name, method = self.route(user_query, previous_expert=previous_expert)
390
+ config = get_config(expert_name)
391
+ effective_max_tokens = max_tokens if max_tokens is not None else config.max_tokens
392
+
393
+ print(f"[ROUTER] เลือก Expert: {expert_name} (วิธี: {method}) | max_tokens={effective_max_tokens}")
394
+
395
+ expert_model = self.experts[expert_name]
396
+ prompt = self.format_prompt(user_query, history=history)
397
+ truncated = False
398
+
399
+ try:
400
+ if expert_name in ("python", "typescript"):
401
+ raw_response, _was_retried = self._generate_code_response(
402
+ expert_model, prompt, config, effective_max_tokens
403
+ )
404
+ else:
405
+ logits_processors = make_logits_processors(
406
+ repetition_penalty=REPETITION_PENALTY,
407
+ repetition_context_size=REPETITION_CONTEXT_SIZE,
408
+ )
409
+ sampler = make_sampler(temp=config.temp, top_p=config.top_p)
410
+ raw_response = generate(
411
+ expert_model, self.tokenizer, prompt=prompt,
412
+ max_tokens=effective_max_tokens,
413
+ sampler=sampler,
414
+ logits_processors=logits_processors,
415
+ )
416
+
417
+ if is_repetitive_response(raw_response):
418
+ print("[WARNING] ⚠️ Reasoning Expert ติด Repetition Loop — Retry ครั้งเดียว...")
419
+ retry_sampler = make_sampler(temp=min(config.temp + 0.2, 1.0), top_p=config.top_p)
420
+ retry_response = generate(
421
+ expert_model, self.tokenizer, prompt=prompt,
422
+ max_tokens=effective_max_tokens,
423
+ sampler=retry_sampler,
424
+ logits_processors=logits_processors,
425
+ )
426
+ if not is_repetitive_response(retry_response):
427
+ raw_response = retry_response
428
+ else:
429
+ print("[WARNING] ⚠️ Retry แล้วยังติด Loop อยู่ — ยอมรับผลลัพธ์เดิมไปก่อน (ไม่ Retry ซ้ำเพื่อคุม Latency)")
430
+
431
+ raw_truncated = self.check_truncation(raw_response, effective_max_tokens)
432
+
433
+ if expert_name == "reasoning":
434
+ print("[INFO] แปลผลลัพธ์เป็นไทย (Reasoning Expert)...")
435
+ final_response, trans_truncated = self.translate_to_thai(raw_response, max_tokens=effective_max_tokens)
436
+ truncated = raw_truncated or trans_truncated
437
+ else:
438
+ final_response = raw_response
439
+ truncated = raw_truncated
440
+
441
+ final_response = enforce_persona(final_response)
442
+
443
+ if truncated:
444
+ print("[WARNING] ⚠️ คำตอบอาจถูกตัดกลางคันเพราะใกล้ max_tokens — ควรเพิ่มค่านี้ถ้าเกิดบ่อย")
445
+
446
+ except Exception as e:
447
+ final_response = f"[ERROR] เกิดข้อผิดพลาดระหว่าง Generate: {str(e)}"
448
+
449
+ latency = time.time() - start_time
450
+ self.log_interaction(user_query, expert_name, method, latency, truncated)
451
+ print(f"[INFO] ใช้เวลา: {latency:.2f} วินาที")
452
+
453
+ self._last_expert_used = expert_name
454
+
455
+ return final_response
456
+
457
+ # ---------- Interactive Chat Loop ----------
458
+ def run_interactive(self):
459
+ print("=" * 60)
460
+ print("🤖 MoE Multi-Expert System — พิมพ์คำถามได้เลย")
461
+ print(" พิมพ์ 'exit' หรือ 'quit' เพื่อออกจากโปรแกรม")
462
+ print("=" * 60 + "\n")
463
+
464
+ history = []
465
+ last_expert = None
466
+
467
+ while True:
468
+ try:
469
+ user_input = input("คุณ: ").strip()
470
+ except (KeyboardInterrupt, EOFError):
471
+ print("\n[INFO] ออกจากโปรแกรม...")
472
+ break
473
+
474
+ if not user_input:
475
+ continue
476
+
477
+ if user_input.lower() in ["exit", "quit", "ออก"]:
478
+ print("[INFO] ออกจากโปรแกรม... แล้วพบกันใหม่ครับ 👋")
479
+ break
480
+
481
+ print()
482
+ print("🤖 ระบบ: ", end="", flush=True)
483
+ full_response = ""
484
+ for chunk in self.query_stream(user_input, history=history, previous_expert=last_expert):
485
+ print(chunk, end="", flush=True)
486
+ full_response += chunk
487
+ print("\n")
488
+ print("-" * 60 + "\n")
489
+
490
+ history.append({"role": "user", "content": user_input})
491
+ history.append({"role": "assistant", "content": full_response})
492
+ last_expert = self._last_expert_used
493
+
494
+
495
+ if __name__ == "__main__":
496
+ orchestrator = MoEOrchestrator(
497
+ base_model_path="./output-moe-mlx-4bit",
498
+ adapter_paths={
499
+ "python": "./adapters/expert-1-python",
500
+ "typescript": "./adapters/expert-2-typescript",
501
+ "reasoning": "./adapters/expert-3-reasoning",
502
+ }
503
+ )
504
+
505
+ orchestrator.run_interactive()