davidtran999 commited on
Commit
f203d03
·
verified ·
1 Parent(s): 7017222

Upload hue_portal/chatbot/chatbot.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. hue_portal/chatbot/chatbot.py +613 -97
hue_portal/chatbot/chatbot.py CHANGED
@@ -6,12 +6,14 @@ import copy
6
  import logging
7
  import json
8
  import time
 
 
9
  from typing import Dict, Any, Optional
10
  from hue_portal.core.chatbot import Chatbot as CoreChatbot, get_chatbot as get_core_chatbot
11
- from hue_portal.chatbot.router import decide_route, IntentRoute, RouteDecision
12
  from hue_portal.chatbot.context_manager import ConversationContext
13
  from hue_portal.chatbot.llm_integration import LLMGenerator
14
- from hue_portal.core.models import LegalSection
15
  from hue_portal.chatbot.exact_match_cache import ExactMatchCache
16
  from hue_portal.chatbot.slow_path_handler import SlowPathHandler
17
 
@@ -27,8 +29,7 @@ DEBUG_SESSION_ID = "debug-session"
27
  DEBUG_RUN_ID = "pre-fix"
28
 
29
  #region agent log
30
- def _agent_debug_log(hypothesis_id: str, location: str, message: str, data: Dict[str, Any]) -> None:
31
- """Append instrumentation logs to .cursor/debug.log in NDJSON format."""
32
  try:
33
  payload = {
34
  "sessionId": DEBUG_SESSION_ID,
@@ -42,7 +43,6 @@ def _agent_debug_log(hypothesis_id: str, location: str, message: str, data: Dict
42
  with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as log_file:
43
  log_file.write(json.dumps(payload, ensure_ascii=False) + "\n")
44
  except Exception:
45
- # Silently ignore logging errors to avoid impacting runtime behavior.
46
  pass
47
  #endregion
48
 
@@ -55,6 +55,8 @@ class Chatbot(CoreChatbot):
55
  def __init__(self):
56
  super().__init__()
57
  self.llm_generator = None
 
 
58
  self._initialize_llm()
59
 
60
  def _initialize_llm(self):
@@ -89,17 +91,51 @@ class Chatbot(CoreChatbot):
89
  except Exception as e:
90
  print(f"⚠️ Failed to save user message: {e}")
91
 
 
 
 
 
 
 
 
 
 
92
  # Classify intent
93
  intent, confidence = self.classify_intent(query)
94
 
95
- # Router decision
96
  route_decision = decide_route(query, intent, confidence)
97
 
98
  # Use forced intent if router suggests it
99
  if route_decision.forced_intent:
100
  intent = route_decision.forced_intent
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
  # Instant exact-match cache lookup
 
 
 
 
103
  cached_response = EXACT_MATCH_CACHE.get(query, intent)
104
  if cached_response:
105
  cached_response["_cache"] = "exact_match"
@@ -124,10 +160,381 @@ class Chatbot(CoreChatbot):
124
  except Exception as e:
125
  print(f"⚠️ Failed to save cached bot message: {e}")
126
  return cached_response
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
 
128
  # Always send legal intent through Slow Path RAG
129
  if intent == "search_legal":
130
- response = self._run_slow_path_legal(query, intent, session_id, route_decision)
 
 
 
 
 
 
131
  elif route_decision.route == IntentRoute.GREETING:
132
  response = {
133
  "message": "Xin chào! Tôi có thể giúp bạn tra cứu các thông tin liên quan về các văn bản quy định pháp luật về xử lí kỷ luật cán bộ đảng viên",
@@ -139,16 +546,24 @@ class Chatbot(CoreChatbot):
139
  }
140
 
141
  elif route_decision.route == IntentRoute.SMALL_TALK:
142
- # Xử lý follow-up questions trong context cho các câu như:
143
- # - "Có điều khoản liên quan nào khác không?"
144
- # - "Tóm tắt nội dung chính của điều này?"
145
- follow_up_keywords = ["có điều khoản", "liên quan", "khác", "nữa", "thêm", "tóm tắt", "tải file"]
 
 
 
 
 
 
 
 
146
  query_lower = query.lower()
147
  is_follow_up = any(kw in query_lower for kw in follow_up_keywords)
148
  #region agent log
149
  _agent_debug_log(
150
- hypothesis_id="H1",
151
- location="chatbot.py:120",
152
  message="follow_up_detection",
153
  data={
154
  "query": query,
@@ -157,112 +572,146 @@ class Chatbot(CoreChatbot):
157
  },
158
  )
159
  #endregion
160
-
161
  response = None
162
-
163
- # Nếu là follow-up question, thử tìm context từ conversation trước
164
  if is_follow_up and session_id:
165
- try:
166
- recent_messages = ConversationContext.get_recent_messages(session_id, limit=5)
167
- #region agent log
168
- _agent_debug_log(
169
- hypothesis_id="H2",
170
- location="chatbot.py:130",
171
- message="recent_messages_loaded",
172
- data={
173
- "messages_count": len(recent_messages),
174
- "session_id": session_id,
175
- },
176
- )
177
- #endregion
178
- # Tìm message bot cuối cùng có intent search_legal
179
- for msg in reversed(recent_messages):
180
- if msg.role == "bot" and msg.intent == "search_legal":
181
- previous_answer = msg.content or ""
182
 
183
- if "tóm tắt" in query_lower:
184
- # Ưu tiên dùng LLM để tóm tắt lại câu trả lời trước đó
185
- summary_message = None
186
- if getattr(self, "llm_generator", None):
187
- try:
188
- prompt = (
189
- "Bạn chuyên gia pháp luật. Hãy tóm tắt ngắn gọn, rõ ràng nội dung chính của đoạn sau "
190
- "(giữ nguyên tinh thần và các mức, tỷ lệ, hình thức kỷ luật nếu có):\n\n"
191
- f"{previous_answer}"
192
- )
193
- summary_message = self.llm_generator.generate_answer(
194
- prompt,
195
- context=None,
196
- documents=None,
197
- )
198
- except Exception as e:
199
- logger.warning("[FOLLOW_UP] LLM summary failed: %s", e)
200
 
201
- if summary_message:
202
- message = summary_message
203
- else:
204
- # Fallback: cắt ngắn nội dung trước đó
205
- content_preview = previous_answer[:400] + "..." if len(previous_answer) > 400 else previous_answer
206
- message = (
207
- "Tóm tắt nội dung chính của điều khoản trước đó:\n\n"
208
- f"{content_preview}"
209
- )
210
- elif "tải" in query_lower:
211
- message = (
212
- "Bạn có thể tải file gốc của văn bản tại mục Quản lý văn bản trên hệ thống "
213
- "hoặc liên hệ cán bộ phụ trách để được cung cấp bản đầy đủ."
214
  )
215
- else:
216
- message = (
217
- "Trong câu trả lời trước, tôi đã trích dẫn điều khoản chính liên quan. "
218
- "Nếu bạn cần điều khoản khác (ví dụ về thẩm quyền, trình tự, hồ sơ), "
219
- "hãy nêu rõ nội dung muốn tìm để tôi trợ giúp nhanh nhất."
220
  )
 
 
221
 
222
- response = {
223
- "message": message,
224
- "intent": "search_legal",
225
- "confidence": 0.85,
226
- "results": [],
227
- "count": 0,
228
- "routing": "follow_up",
229
- }
230
- #region agent log
231
- _agent_debug_log(
232
- hypothesis_id="H3",
233
- location="chatbot.py:173",
234
- message="follow_up_response_created",
235
- data={
236
- "query": query,
237
- "message_length": len(message),
238
- "used_llm": bool("tóm tắt" in query_lower and getattr(self, "llm_generator", None)),
239
- },
240
  )
241
- #endregion
242
- break
243
- except Exception as e:
244
- logger.warning("[FOLLOW_UP] Failed to process follow-up: %s", e)
 
 
 
 
 
 
 
 
245
 
246
- # Nếu không phải follow-up hoặc không tìm thấy context, trả về message thân thiện mặc định
 
 
 
 
 
 
 
 
 
247
  if response is None:
248
  #region agent log
249
  _agent_debug_log(
250
  hypothesis_id="H1",
251
- location="chatbot.py:187",
252
- message="follow_up_fallback_small_talk",
253
  data={
254
  "is_follow_up": is_follow_up,
255
  "session_id_present": bool(session_id),
256
  },
257
  )
258
  #endregion
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  response = {
260
- "message": "Tôi có thể giúp bạn tra cứu các văn bản quy định pháp luật về xử lí kỷ luật cán bộ đảng viên. Bạn muốn tìm gì?",
261
  "intent": intent,
262
  "confidence": confidence,
263
  "results": [],
264
  "count": 0,
265
- "routing": "small_talk",
266
  }
267
 
268
  else: # IntentRoute.SEARCH
@@ -288,6 +737,18 @@ class Chatbot(CoreChatbot):
288
  "routing": "search"
289
  }
290
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  # Add session_id
292
  if session_id:
293
  response["session_id"] = session_id
@@ -295,10 +756,11 @@ class Chatbot(CoreChatbot):
295
  # Save bot response to context
296
  if session_id:
297
  try:
 
298
  ConversationContext.add_message(
299
  session_id=session_id,
300
  role="bot",
301
- content=response.get("message", ""),
302
  intent=intent
303
  )
304
  except Exception as e:
@@ -314,10 +776,19 @@ class Chatbot(CoreChatbot):
314
  intent: str,
315
  session_id: Optional[str],
316
  route_decision: RouteDecision,
 
317
  ) -> Dict[str, Any]:
318
  """Execute Slow Path legal handler (with fast-path + structured output)."""
319
  slow_handler = SlowPathHandler()
320
- response = slow_handler.handle(query, intent, session_id)
 
 
 
 
 
 
 
 
321
  response.setdefault("routing", "slow_path")
322
  response.setdefault(
323
  "_routing",
@@ -327,6 +798,30 @@ class Chatbot(CoreChatbot):
327
  "confidence": route_decision.confidence,
328
  },
329
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  logger.info(
331
  "[LEGAL] Slow path response - source=%s count=%s routing=%s",
332
  response.get("_source"),
@@ -357,6 +852,8 @@ class Chatbot(CoreChatbot):
357
 
358
  def _should_cache_response(self, intent: str, response: Dict[str, Any]) -> bool:
359
  """Determine if response should be cached for exact matches."""
 
 
360
  cacheable_intents = {
361
  "search_legal",
362
  "search_fine",
@@ -371,6 +868,25 @@ class Chatbot(CoreChatbot):
371
  if not response.get("results"):
372
  return False
373
  return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
 
375
  def _handle_legal_query(self, query: str, session_id: Optional[str] = None) -> Dict[str, Any]:
376
  """
 
6
  import logging
7
  import json
8
  import time
9
+ import unicodedata
10
+ import re
11
  from typing import Dict, Any, Optional
12
  from hue_portal.core.chatbot import Chatbot as CoreChatbot, get_chatbot as get_core_chatbot
13
+ from hue_portal.chatbot.router import decide_route, IntentRoute, RouteDecision, DOCUMENT_CODE_PATTERNS
14
  from hue_portal.chatbot.context_manager import ConversationContext
15
  from hue_portal.chatbot.llm_integration import LLMGenerator
16
+ from hue_portal.core.models import LegalSection, LegalDocument
17
  from hue_portal.chatbot.exact_match_cache import ExactMatchCache
18
  from hue_portal.chatbot.slow_path_handler import SlowPathHandler
19
 
 
29
  DEBUG_RUN_ID = "pre-fix"
30
 
31
  #region agent log
32
+ def _agent_debug_log(hypothesis_id: str, location: str, message: str, data: Dict[str, Any]):
 
33
  try:
34
  payload = {
35
  "sessionId": DEBUG_SESSION_ID,
 
43
  with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as log_file:
44
  log_file.write(json.dumps(payload, ensure_ascii=False) + "\n")
45
  except Exception:
 
46
  pass
47
  #endregion
48
 
 
55
  def __init__(self):
56
  super().__init__()
57
  self.llm_generator = None
58
+ # Cache in-memory: giữ câu trả lời legal gần nhất theo session để xử lý follow-up nhanh
59
+ self._last_legal_answer_by_session: Dict[str, str] = {}
60
  self._initialize_llm()
61
 
62
  def _initialize_llm(self):
 
91
  except Exception as e:
92
  print(f"⚠️ Failed to save user message: {e}")
93
 
94
+ session_metadata: Dict[str, Any] = {}
95
+ selected_doc_code: Optional[str] = None
96
+ if session_id:
97
+ try:
98
+ session_metadata = ConversationContext.get_session_metadata(session_id)
99
+ selected_doc_code = session_metadata.get("selected_document_code")
100
+ except Exception:
101
+ session_metadata = {}
102
+
103
  # Classify intent
104
  intent, confidence = self.classify_intent(query)
105
 
106
+ # Router decision (using raw intent)
107
  route_decision = decide_route(query, intent, confidence)
108
 
109
  # Use forced intent if router suggests it
110
  if route_decision.forced_intent:
111
  intent = route_decision.forced_intent
112
+
113
+ # Nếu session đã có selected_document_code (user đã chọn văn bản ở wizard)
114
+ # thì luôn ép intent về search_legal và route sang SEARCH,
115
+ # tránh bị kẹt ở nhánh small-talk/off-topic do nội dung câu hỏi ban đầu.
116
+ if selected_doc_code:
117
+ intent = "search_legal"
118
+ route_decision.route = IntentRoute.SEARCH
119
+ route_decision.forced_intent = "search_legal"
120
+
121
+ # Map tất cả intent tra cứu nội dung về search_legal
122
+ domain_search_intents = {
123
+ "search_fine",
124
+ "search_procedure",
125
+ "search_office",
126
+ "search_advisory",
127
+ "general_query",
128
+ }
129
+ if intent in domain_search_intents:
130
+ intent = "search_legal"
131
+ route_decision.route = IntentRoute.SEARCH
132
+ route_decision.forced_intent = "search_legal"
133
 
134
  # Instant exact-match cache lookup
135
+ # ⚠️ Tắt cache cho intent search_legal để luôn đi qua wizard / Slow Path,
136
+ # tránh trả lại các câu trả lời cũ không có options.
137
+ cached_response = None
138
+ if intent != "search_legal":
139
  cached_response = EXACT_MATCH_CACHE.get(query, intent)
140
  if cached_response:
141
  cached_response["_cache"] = "exact_match"
 
160
  except Exception as e:
161
  print(f"⚠️ Failed to save cached bot message: {e}")
162
  return cached_response
163
+
164
+ # Wizard / option-first ngay tại chatbot layer:
165
+ # Multi-stage wizard flow:
166
+ # Stage 1: Choose document (if no document selected)
167
+ # Stage 2: Choose topic/section (if document selected but no topic)
168
+ # Stage 3: Choose detail (if topic selected, ask for more details)
169
+ # Final: Answer (when user says "Không" or after detail selection)
170
+
171
+ has_doc_code_in_query = self._query_has_document_code(query)
172
+ wizard_stage = session_metadata.get("wizard_stage") if session_metadata else None
173
+ selected_topic = session_metadata.get("selected_topic") if session_metadata else None
174
+ wizard_depth = session_metadata.get("wizard_depth", 0) if session_metadata else 0
175
+
176
+ print(f"[WIZARD] Chatbot layer check - intent={intent}, wizard_stage={wizard_stage}, selected_doc_code={selected_doc_code}, selected_topic={selected_topic}, has_doc_code_in_query={has_doc_code_in_query}, query='{query[:50]}'")
177
+
178
+ # Stage 1: Choose document (if no document selected and no code in query)
179
+ # Use Query Rewrite Strategy from slow_path_handler instead of old LLM suggestions
180
+ if intent == "search_legal" and not selected_doc_code and not has_doc_code_in_query:
181
+ print("[WIZARD] ✅ Stage 1: Using Query Rewrite Strategy from slow_path_handler")
182
+ # Delegate to slow_path_handler which has Query Rewrite Strategy
183
+ slow_handler = SlowPathHandler()
184
+ response = slow_handler.handle(
185
+ query=query,
186
+ intent=intent,
187
+ session_id=session_id,
188
+ selected_document_code=None, # No document selected yet
189
+ )
190
+
191
+ # Ensure response has wizard metadata
192
+ if response:
193
+ response.setdefault("wizard_stage", "choose_document")
194
+ response.setdefault("routing", "legal_wizard")
195
+ response.setdefault("type", "options")
196
+
197
+ # Update session metadata
198
+ if session_id:
199
+ try:
200
+ ConversationContext.update_session_metadata(
201
+ session_id,
202
+ {
203
+ "wizard_stage": "choose_document",
204
+ "wizard_depth": 1,
205
+ }
206
+ )
207
+ except Exception as e:
208
+ logger.warning("[WIZARD] Failed to update session metadata: %s", e)
209
+
210
+ # Save bot message to context
211
+ if session_id:
212
+ try:
213
+ bot_message = response.get("message") or response.get("clarification", {}).get("message", "")
214
+ ConversationContext.add_message(
215
+ session_id=session_id,
216
+ role="bot",
217
+ content=bot_message,
218
+ intent=intent,
219
+ )
220
+ except Exception as e:
221
+ print(f"⚠️ Failed to save wizard bot message: {e}")
222
+
223
+ return response if response else {
224
+ "message": "Xin lỗi, có lỗi xảy ra khi tìm kiếm văn bản.",
225
+ "intent": intent,
226
+ "results": [],
227
+ "count": 0,
228
+ }
229
+
230
+ # Stage 2: Choose topic/section (if document selected but no topic yet)
231
+ # Skip if wizard_stage is already "answer" (user wants final answer)
232
+ if intent == "search_legal" and selected_doc_code and not selected_topic and not has_doc_code_in_query and wizard_stage != "answer":
233
+ print("[WIZARD] ✅ Stage 2 triggered: Choose topic/section")
234
+
235
+ # Get document title
236
+ document_title = selected_doc_code
237
+ try:
238
+ doc = LegalDocument.objects.filter(code=selected_doc_code).first()
239
+ if doc:
240
+ document_title = getattr(doc, "title", "") or selected_doc_code
241
+ except Exception:
242
+ pass
243
+
244
+ # Extract keywords from query for parallel search
245
+ search_keywords_from_query = []
246
+ if self.llm_generator:
247
+ try:
248
+ conversation_context = None
249
+ if session_id:
250
+ try:
251
+ recent_messages = ConversationContext.get_recent_messages(session_id, limit=5)
252
+ conversation_context = [
253
+ {"role": msg.role, "content": msg.content}
254
+ for msg in recent_messages
255
+ ]
256
+ except Exception:
257
+ pass
258
+
259
+ search_keywords_from_query = self.llm_generator.extract_search_keywords(
260
+ query=query,
261
+ selected_options=None, # No options selected yet
262
+ conversation_context=conversation_context,
263
+ )
264
+ print(f"[WIZARD] Extracted keywords: {search_keywords_from_query[:5]}")
265
+ except Exception as exc:
266
+ logger.warning("[WIZARD] Keyword extraction failed: %s", exc)
267
+
268
+ # Fallback to simple keyword extraction
269
+ if not search_keywords_from_query:
270
+ search_keywords_from_query = self.chatbot.extract_keywords(query)
271
+
272
+ # Trigger parallel search for document (if not already done)
273
+ slow_handler = SlowPathHandler()
274
+ prefetched_results = slow_handler._get_prefetched_results(session_id, "document_results")
275
+
276
+ if not prefetched_results:
277
+ # Trigger parallel search now
278
+ slow_handler._parallel_search_prepare(
279
+ document_code=selected_doc_code,
280
+ keywords=search_keywords_from_query,
281
+ session_id=session_id,
282
+ )
283
+ logger.info("[WIZARD] Triggered parallel search for document")
284
+
285
+ # Get prefetched search results from parallel search (if available)
286
+ prefetched_results = slow_handler._get_prefetched_results(session_id, "document_results")
287
+ search_results = []
288
+
289
+ if prefetched_results:
290
+ search_results = prefetched_results.get("results", [])
291
+ logger.info("[WIZARD] Using prefetched results: %d sections", len(search_results))
292
+ else:
293
+ # Fallback: search synchronously if prefetch not ready
294
+ search_result = slow_handler._search_by_intent(
295
+ intent="search_legal",
296
+ query=query,
297
+ limit=20,
298
+ preferred_document_code=selected_doc_code.upper(),
299
+ )
300
+ search_results = search_result.get("results", [])
301
+ logger.info("[WIZARD] Fallback search: %d sections", len(search_results))
302
+
303
+ # Extract keywords for topic options
304
+ conversation_context = None
305
+ if session_id:
306
+ try:
307
+ recent_messages = ConversationContext.get_recent_messages(session_id, limit=5)
308
+ conversation_context = [
309
+ {"role": msg.role, "content": msg.content}
310
+ for msg in recent_messages
311
+ ]
312
+ except Exception:
313
+ pass
314
+
315
+ # Use LLM to generate topic options
316
+ topic_options = []
317
+ intro_message = f"Bạn muốn tìm điều khoản/chủ đề nào cụ thể trong {document_title}?"
318
+ search_keywords = []
319
+
320
+ if self.llm_generator:
321
+ try:
322
+ llm_payload = self.llm_generator.suggest_topic_options(
323
+ query=query,
324
+ document_code=selected_doc_code,
325
+ document_title=document_title,
326
+ search_results=search_results[:10], # Top 10 for options
327
+ conversation_context=conversation_context,
328
+ max_options=3,
329
+ )
330
+ if llm_payload:
331
+ intro_message = llm_payload.get("message") or intro_message
332
+ topic_options = llm_payload.get("options", [])
333
+ search_keywords = llm_payload.get("search_keywords", [])
334
+ print(f"[WIZARD] ✅ LLM generated {len(topic_options)} topic options")
335
+ except Exception as exc:
336
+ logger.warning("[WIZARD] LLM topic suggestion failed: %s", exc)
337
+
338
+ # Fallback: build options from search results
339
+ if not topic_options and search_results:
340
+ for result in search_results[:3]:
341
+ data = result.get("data", {})
342
+ section_title = data.get("section_title") or data.get("title") or ""
343
+ article = data.get("article") or data.get("article_number") or ""
344
+ if section_title or article:
345
+ topic_options.append({
346
+ "title": section_title or article,
347
+ "article": article,
348
+ "reason": data.get("excerpt", "")[:100] or "",
349
+ "keywords": [],
350
+ })
351
+
352
+ # If still no options, create generic ones
353
+ if not topic_options:
354
+ topic_options = [
355
+ {
356
+ "title": "Các điều khoản liên quan",
357
+ "article": "",
358
+ "reason": "Tìm kiếm các điều khoản liên quan đến câu hỏi của bạn",
359
+ "keywords": [],
360
+ }
361
+ ]
362
+
363
+ # Trigger parallel search for selected keywords
364
+ if search_keywords:
365
+ slow_handler._parallel_search_topic(
366
+ document_code=selected_doc_code,
367
+ topic_keywords=search_keywords,
368
+ session_id=session_id,
369
+ )
370
+
371
+ response = {
372
+ "message": intro_message,
373
+ "intent": intent,
374
+ "confidence": confidence,
375
+ "results": [],
376
+ "count": 0,
377
+ "routing": "legal_wizard",
378
+ "type": "options",
379
+ "wizard_stage": "choose_topic",
380
+ "clarification": {
381
+ "message": intro_message,
382
+ "options": topic_options,
383
+ },
384
+ "options": topic_options,
385
+ }
386
+ if session_id:
387
+ response["session_id"] = session_id
388
+ try:
389
+ ConversationContext.add_message(
390
+ session_id=session_id,
391
+ role="bot",
392
+ content=intro_message,
393
+ intent=intent,
394
+ )
395
+ ConversationContext.update_session_metadata(
396
+ session_id,
397
+ {
398
+ "wizard_stage": "choose_topic",
399
+ },
400
+ )
401
+ except Exception as e:
402
+ print(f"⚠️ Failed to save Stage 2 bot message: {e}")
403
+ return response
404
+
405
+ # Stage 3: Choose detail (if topic selected, ask if user wants more details)
406
+ # Skip if wizard_stage is already "answer" (user wants final answer)
407
+ if intent == "search_legal" and selected_doc_code and selected_topic and wizard_stage != "answer":
408
+ # Check if user is asking for more details or saying "Không"
409
+ query_lower = query.lower()
410
+ wants_more = any(kw in query_lower for kw in ["có", "cần", "muốn", "thêm", "chi tiết", "nữa"])
411
+ says_no = any(kw in query_lower for kw in ["không", "khong", "thôi", "đủ", "xong"])
412
+
413
+ if says_no or wizard_depth >= 2:
414
+ # User doesn't want more details or already asked twice - proceed to final answer
415
+ print("[WIZARD] ✅ User wants final answer, proceeding to slow_path")
416
+ # Clear wizard stage to allow normal answer flow
417
+ if session_id:
418
+ try:
419
+ ConversationContext.update_session_metadata(
420
+ session_id,
421
+ {
422
+ "wizard_stage": "answer",
423
+ },
424
+ )
425
+ except Exception:
426
+ pass
427
+ elif wants_more or wizard_depth == 0:
428
+ # User wants more details - generate detail options
429
+ print("[WIZARD] ✅ Stage 3 triggered: Choose detail")
430
+
431
+ # Get conversation context
432
+ conversation_context = None
433
+ if session_id:
434
+ try:
435
+ recent_messages = ConversationContext.get_recent_messages(session_id, limit=5)
436
+ conversation_context = [
437
+ {"role": msg.role, "content": msg.content}
438
+ for msg in recent_messages
439
+ ]
440
+ except Exception:
441
+ pass
442
+
443
+ # Use LLM to generate detail options
444
+ detail_options = []
445
+ intro_message = "Bạn muốn chi tiết gì cho chủ đề này nữa không?"
446
+ search_keywords = []
447
+
448
+ if self.llm_generator:
449
+ try:
450
+ llm_payload = self.llm_generator.suggest_detail_options(
451
+ query=query,
452
+ selected_document_code=selected_doc_code,
453
+ selected_topic=selected_topic,
454
+ conversation_context=conversation_context,
455
+ max_options=3,
456
+ )
457
+ if llm_payload:
458
+ intro_message = llm_payload.get("message") or intro_message
459
+ detail_options = llm_payload.get("options", [])
460
+ search_keywords = llm_payload.get("search_keywords", [])
461
+ print(f"[WIZARD] ✅ LLM generated {len(detail_options)} detail options")
462
+ except Exception as exc:
463
+ logger.warning("[WIZARD] LLM detail suggestion failed: %s", exc)
464
+
465
+ # Fallback options
466
+ if not detail_options:
467
+ detail_options = [
468
+ {
469
+ "title": "Thẩm quyền xử lý",
470
+ "reason": "Tìm hiểu về thẩm quyền xử lý kỷ luật",
471
+ "keywords": ["thẩm quyền", "xử lý"],
472
+ },
473
+ {
474
+ "title": "Trình tự, thủ tục",
475
+ "reason": "Tìm hiểu về trình tự, thủ tục xử lý",
476
+ "keywords": ["trình tự", "thủ tục"],
477
+ },
478
+ {
479
+ "title": "Hình thức kỷ luật",
480
+ "reason": "Tìm hiểu về các hình thức kỷ luật",
481
+ "keywords": ["hình thức", "kỷ luật"],
482
+ },
483
+ ]
484
+
485
+ # Trigger parallel search for detail keywords
486
+ if search_keywords and session_id:
487
+ slow_handler = SlowPathHandler()
488
+ slow_handler._parallel_search_topic(
489
+ document_code=selected_doc_code,
490
+ topic_keywords=search_keywords,
491
+ session_id=session_id,
492
+ )
493
+
494
+ response = {
495
+ "message": intro_message,
496
+ "intent": intent,
497
+ "confidence": confidence,
498
+ "results": [],
499
+ "count": 0,
500
+ "routing": "legal_wizard",
501
+ "type": "options",
502
+ "wizard_stage": "choose_detail",
503
+ "clarification": {
504
+ "message": intro_message,
505
+ "options": detail_options,
506
+ },
507
+ "options": detail_options,
508
+ }
509
+ if session_id:
510
+ response["session_id"] = session_id
511
+ try:
512
+ ConversationContext.add_message(
513
+ session_id=session_id,
514
+ role="bot",
515
+ content=intro_message,
516
+ intent=intent,
517
+ )
518
+ ConversationContext.update_session_metadata(
519
+ session_id,
520
+ {
521
+ "wizard_stage": "choose_detail",
522
+ "wizard_depth": wizard_depth + 1,
523
+ },
524
+ )
525
+ except Exception as e:
526
+ print(f"⚠️ Failed to save Stage 3 bot message: {e}")
527
+ return response
528
 
529
  # Always send legal intent through Slow Path RAG
530
  if intent == "search_legal":
531
+ response = self._run_slow_path_legal(
532
+ query,
533
+ intent,
534
+ session_id,
535
+ route_decision,
536
+ session_metadata=session_metadata,
537
+ )
538
  elif route_decision.route == IntentRoute.GREETING:
539
  response = {
540
  "message": "Xin chào! Tôi có thể giúp bạn tra cứu các thông tin liên quan về các văn bản quy định pháp luật về xử lí kỷ luật cán bộ đảng viên",
 
546
  }
547
 
548
  elif route_decision.route == IntentRoute.SMALL_TALK:
549
+ # Xử lý follow-up questions trong context
550
+ follow_up_keywords = [
551
+ " điều khoản",
552
+ "liên quan",
553
+ "khác",
554
+ "nữa",
555
+ "thêm",
556
+ "tóm tắt",
557
+ "tải file",
558
+ "tải",
559
+ "download",
560
+ ]
561
  query_lower = query.lower()
562
  is_follow_up = any(kw in query_lower for kw in follow_up_keywords)
563
  #region agent log
564
  _agent_debug_log(
565
+ hypothesis_id="H2",
566
+ location="chatbot.py:119",
567
  message="follow_up_detection",
568
  data={
569
  "query": query,
 
572
  },
573
  )
574
  #endregion
575
+
576
  response = None
577
+
578
+ # Nếu là follow-up question, ưu tiên dùng context legal gần nhất trong session
579
  if is_follow_up and session_id:
580
+ previous_answer = self._last_legal_answer_by_session.get(session_id, "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
581
 
582
+ # Nếu chưa có trong cache in-memory, fallback sang ConversationContext DB
583
+ if not previous_answer:
584
+ try:
585
+ recent_messages = ConversationContext.get_recent_messages(session_id, limit=5)
586
+ for msg in reversed(recent_messages):
587
+ if msg.role == "bot" and msg.intent == "search_legal":
588
+ previous_answer = msg.content or ""
589
+ break
590
+ except Exception as e:
591
+ logger.warning("[FOLLOW_UP] Failed to load context from DB: %s", e)
 
 
 
 
 
 
 
592
 
593
+ if previous_answer:
594
+ if "tóm tắt" in query_lower:
595
+ summary_message = None
596
+ if getattr(self, "llm_generator", None):
597
+ try:
598
+ prompt = (
599
+ "Bạn là chuyên gia pháp luật. Hãy tóm tắt ngắn gọn, rõ ràng nội dung chính của đoạn sau "
600
+ "(giữ nguyên tinh thần và các mức, tỷ lệ, hình thức kỷ luật nếu có):\n\n"
601
+ f"{previous_answer}"
 
 
 
 
602
  )
603
+ summary_message = self.llm_generator.generate_answer(
604
+ prompt,
605
+ context=None,
606
+ documents=None,
 
607
  )
608
+ except Exception as e:
609
+ logger.warning("[FOLLOW_UP] LLM summary failed: %s", e)
610
 
611
+ if summary_message:
612
+ message = summary_message
613
+ else:
614
+ content_preview = (
615
+ previous_answer[:400] + "..." if len(previous_answer) > 400 else previous_answer
 
 
 
 
 
 
 
 
 
 
 
 
 
616
  )
617
+ message = "Tóm tắt nội dung chính của điều khoản trước đó:\n\n" f"{content_preview}"
618
+ elif "tải" in query_lower:
619
+ message = (
620
+ "Bạn thể tải file gốc của văn bản tại mục Quản lý văn bản trên hệ thống "
621
+ "hoặc liên hệ cán bộ phụ trách để được cung cấp bản đầy đủ."
622
+ )
623
+ else:
624
+ message = (
625
+ "Trong câu trả lời trước, tôi đã trích dẫn điều khoản chính liên quan. "
626
+ "Nếu bạn cần điều khoản khác (ví dụ về thẩm quyền, trình tự, hồ sơ), "
627
+ "hãy nêu rõ nội dung muốn tìm để tôi trợ giúp nhanh nhất."
628
+ )
629
 
630
+ response = {
631
+ "message": message,
632
+ "intent": "search_legal",
633
+ "confidence": 0.85,
634
+ "results": [],
635
+ "count": 0,
636
+ "routing": "follow_up",
637
+ }
638
+
639
+ # Nếu không phải follow-up hoặc không tìm thấy context, trả về message thân thiện
640
  if response is None:
641
  #region agent log
642
  _agent_debug_log(
643
  hypothesis_id="H1",
644
+ location="chatbot.py:193",
645
+ message="follow_up_fallback",
646
  data={
647
  "is_follow_up": is_follow_up,
648
  "session_id_present": bool(session_id),
649
  },
650
  )
651
  #endregion
652
+ # Detect off-topic questions (nấu ăn, chả trứng, etc.)
653
+ off_topic_keywords = ["nấu", "nau", "chả trứng", "cha trung", "món ăn", "mon an", "công thức", "cong thuc",
654
+ "cách làm", "cach lam", "đổ chả", "do cha", "trứng", "trung"]
655
+ is_off_topic = any(kw in query_lower for kw in off_topic_keywords)
656
+
657
+ if is_off_topic:
658
+ # Ngoài phạm vi → từ chối lịch sự + gợi ý wizard với các văn bản pháp lý chính
659
+ intro_message = (
660
+ "Xin lỗi, tôi là chatbot chuyên về tra cứu các văn bản quy định pháp luật "
661
+ "về xử lí kỷ luật cán bộ đảng viên của Phòng Thanh Tra - Công An Thành Phố Huế.\n\n"
662
+ "Tôi không thể trả lời các câu hỏi về nấu ăn, công thức nấu ăn hay các chủ đề khác ngoài phạm vi pháp luật.\n\n"
663
+ "Tuy nhiên, tôi có thể giúp bạn tra cứu một số văn bản pháp luật quan trọng. "
664
+ "Bạn hãy chọn văn bản muốn xem trước:"
665
+ )
666
+ clarification_options = [
667
+ {
668
+ "code": "264-QD-TW",
669
+ "title": "Quyết định 264-QĐ/TW về kỷ luật đảng viên",
670
+ "reason": "Quy định chung về xử lý kỷ luật đối với đảng viên vi phạm.",
671
+ },
672
+ {
673
+ "code": "QD-69-TW",
674
+ "title": "Quy định 69-QĐ/TW về kỷ luật tổ chức đảng, đảng viên",
675
+ "reason": "Quy định chi tiết về các hành vi vi phạm và hình thức kỷ luật.",
676
+ },
677
+ {
678
+ "code": "TT-02-CAND",
679
+ "title": "Thông tư 02/2021/TT-BCA về điều lệnh CAND",
680
+ "reason": "Quy định về điều lệnh, lễ tiết, tác phong trong CAND.",
681
+ },
682
+ {
683
+ "code": "__other__",
684
+ "title": "Khác",
685
+ "reason": "Tôi muốn hỏi văn bản hoặc chủ đề pháp luật khác.",
686
+ },
687
+ ]
688
+ response = {
689
+ "message": intro_message,
690
+ "intent": intent,
691
+ "confidence": confidence,
692
+ "results": [],
693
+ "count": 0,
694
+ "routing": "small_talk_offtopic_wizard",
695
+ "type": "options",
696
+ "wizard_stage": "choose_document",
697
+ "clarification": {
698
+ "message": intro_message,
699
+ "options": clarification_options,
700
+ },
701
+ "options": clarification_options,
702
+ }
703
+ else:
704
+ message = (
705
+ "Tôi có thể giúp bạn tra cứu các văn bản quy định pháp luật về xử lí kỷ luật cán bộ đảng viên. "
706
+ "Bạn muốn tìm gì?"
707
+ )
708
  response = {
709
+ "message": message,
710
  "intent": intent,
711
  "confidence": confidence,
712
  "results": [],
713
  "count": 0,
714
+ "routing": "small_talk",
715
  }
716
 
717
  else: # IntentRoute.SEARCH
 
737
  "routing": "search"
738
  }
739
 
740
+ if session_id and intent == "search_legal":
741
+ try:
742
+ self._last_legal_answer_by_session[session_id] = response.get("message", "") or ""
743
+ except Exception:
744
+ pass
745
+
746
+ # Đánh dấu loại payload cho frontend: answer hay options (wizard)
747
+ if response.get("clarification") or response.get("type") == "options":
748
+ response.setdefault("type", "options")
749
+ else:
750
+ response.setdefault("type", "answer")
751
+
752
  # Add session_id
753
  if session_id:
754
  response["session_id"] = session_id
 
756
  # Save bot response to context
757
  if session_id:
758
  try:
759
+ bot_message = response.get("message") or response.get("clarification", {}).get("message", "")
760
  ConversationContext.add_message(
761
  session_id=session_id,
762
  role="bot",
763
+ content=bot_message,
764
  intent=intent
765
  )
766
  except Exception as e:
 
776
  intent: str,
777
  session_id: Optional[str],
778
  route_decision: RouteDecision,
779
+ session_metadata: Optional[Dict[str, Any]] = None,
780
  ) -> Dict[str, Any]:
781
  """Execute Slow Path legal handler (with fast-path + structured output)."""
782
  slow_handler = SlowPathHandler()
783
+ selected_doc_code = None
784
+ if session_metadata:
785
+ selected_doc_code = session_metadata.get("selected_document_code")
786
+ response = slow_handler.handle(
787
+ query,
788
+ intent,
789
+ session_id,
790
+ selected_document_code=selected_doc_code,
791
+ )
792
  response.setdefault("routing", "slow_path")
793
  response.setdefault(
794
  "_routing",
 
798
  "confidence": route_decision.confidence,
799
  },
800
  )
801
+
802
+ # Cập nhật metadata wizard đơn giản: nếu đang hỏi người dùng chọn văn bản
803
+ # thì đánh dấu stage = choose_document; nếu đã trả lời thì stage = answer.
804
+ if session_id:
805
+ try:
806
+ if response.get("clarification") or response.get("type") == "options":
807
+ ConversationContext.update_session_metadata(
808
+ session_id,
809
+ {
810
+ "wizard_stage": "choose_document",
811
+ },
812
+ )
813
+ else:
814
+ ConversationContext.update_session_metadata(
815
+ session_id,
816
+ {
817
+ "wizard_stage": "answer",
818
+ "last_answer_type": response.get("intent"),
819
+ },
820
+ )
821
+ except Exception:
822
+ # Không để lỗi metadata làm hỏng luồng trả lời chính
823
+ pass
824
+
825
  logger.info(
826
  "[LEGAL] Slow path response - source=%s count=%s routing=%s",
827
  response.get("_source"),
 
852
 
853
  def _should_cache_response(self, intent: str, response: Dict[str, Any]) -> bool:
854
  """Determine if response should be cached for exact matches."""
855
+ if response.get("clarification"):
856
+ return False
857
  cacheable_intents = {
858
  "search_legal",
859
  "search_fine",
 
868
  if not response.get("results"):
869
  return False
870
  return True
871
+
872
+ def _query_has_document_code(self, query: str) -> bool:
873
+ """
874
+ Check if the raw query string explicitly contains a known document code pattern
875
+ (ví dụ: '264/QĐ-TW', 'QD-69-TW', 'TT-02-CAND').
876
+ """
877
+ if not query:
878
+ return False
879
+ # Remove accents để regex đơn giản hơn
880
+ normalized = unicodedata.normalize("NFD", query)
881
+ normalized = "".join(ch for ch in normalized if unicodedata.category(ch) != "Mn")
882
+ normalized = normalized.upper()
883
+ for pattern in DOCUMENT_CODE_PATTERNS:
884
+ try:
885
+ if re.search(pattern, normalized):
886
+ return True
887
+ except re.error:
888
+ continue
889
+ return False
890
 
891
  def _handle_legal_query(self, query: str, session_id: Optional[str] = None) -> Dict[str, Any]:
892
  """