Davidtran99 commited on
Commit
7e53bd3
·
1 Parent(s): 49a1a82

fix: add DISABLE_WIZARD_FLOW support - reset wizard state when disabled

Browse files
backend/hue_portal/chatbot/chatbot.py CHANGED
@@ -136,7 +136,7 @@ class Chatbot(CoreChatbot):
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"
142
  cached_response["_source"] = cached_response.get("_source", "cache")
@@ -167,6 +167,8 @@ class Chatbot(CoreChatbot):
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
@@ -175,151 +177,140 @@ class Chatbot(CoreChatbot):
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
- if intent == "search_legal" and not selected_doc_code and not has_doc_code_in_query:
180
- print("[WIZARD] Chatbot layer wizard triggered, using AI to generate options")
181
- # Load canonical documents từ DB
182
- canonical_candidates = []
183
- try:
184
- canonical_docs = list(
185
- LegalDocument.objects.filter(
186
- code__in=["264-QD-TW", "QD-69-TW", "TT-02-CAND"]
187
- )
188
- )
189
- for doc in canonical_docs:
190
- summary = getattr(doc, "summary", "") or ""
191
- metadata = getattr(doc, "metadata", {}) or {}
192
- if not summary and isinstance(metadata, dict):
193
- summary = metadata.get("summary", "")
194
- canonical_candidates.append(
195
  {
196
- "code": doc.code,
197
- "title": getattr(doc, "title", "") or doc.code,
198
- "summary": summary,
199
- "doc_type": getattr(doc, "doc_type", "") or "",
200
- "section_title": "",
201
  }
202
  )
203
- except Exception as exc:
204
- logger.warning("[WIZARD] Failed to load canonical documents: %s", exc)
205
-
206
- # Fallback nếu không load được từ DB
207
- if not canonical_candidates:
208
- canonical_candidates = [
209
- {
210
- "code": "264-QD-TW",
211
- "title": "Quyết định 264-QĐ/TW về kỷ luật đảng viên",
212
- "summary": "Quy định chung về xử lý kỷ luật đối với đảng viên vi phạm.",
213
- "doc_type": "",
214
- "section_title": "",
215
- },
216
- {
217
- "code": "QD-69-TW",
218
- "title": "Quy định 69-QĐ/TW về kỷ luật tổ chức đảng, đảng viên",
219
- "summary": "Quy định chi tiết về các hành vi vi phạm và hình thức kỷ luật.",
220
- "doc_type": "",
221
- "section_title": "",
222
- },
223
- {
224
- "code": "TT-02-CAND",
225
- "title": "Thông 02/2021/TT-BCA về điều lệnh CAND",
226
- "summary": "Quy định về điều lệnh, lễ tiết, tác phong trong CAND.",
227
- "doc_type": "",
228
- "section_title": "",
229
- },
230
- ]
231
-
232
- # Dùng LLM để đề xuất options dựa trên câu hỏi
233
- clarification_options = []
234
- intro_message = (
235
- "Tôi tìm thấy một số nhóm văn bản có thể liên quan đến câu hỏi của bạn.\n\n"
236
- "Bạn hãy chọn văn bản muốn tra cứu trước, sau đó tôi sẽ trả lời chi tiết hơn:"
237
- )
238
-
239
- if self.llm_generator:
240
  try:
241
- llm_payload = self.llm_generator.suggest_clarification_topics(
242
- query,
243
- canonical_candidates,
244
- max_options=3,
 
 
 
 
245
  )
246
- if llm_payload:
247
- intro_message = llm_payload.get("message") or intro_message
248
- raw_options = llm_payload.get("options")
249
- if isinstance(raw_options, list) and len(raw_options) > 0:
250
- clarification_options = [
251
- {
252
- "code": (opt.get("code") or candidate.get("code", "")).upper(),
253
- "title": opt.get("title") or opt.get("document_title") or candidate.get("title", ""),
254
- "reason": opt.get("reason")
255
- or opt.get("summary")
256
- or candidate.get("summary")
257
- or candidate.get("section_title")
258
- or "",
259
- }
260
- for opt, candidate in zip(
261
- raw_options,
262
- canonical_candidates[: len(raw_options)],
263
- )
264
- if (opt.get("code") or candidate.get("code"))
265
- and (opt.get("title") or opt.get("document_title") or candidate.get("title"))
266
- ]
267
- print(f"[WIZARD] LLM generated {len(clarification_options)} options")
268
- except Exception as exc:
269
- logger.warning("[WIZARD] LLM suggestion failed: %s, using fallback", exc)
 
 
 
270
 
271
- # Fallback nếu LLM không trả về options hợp lệ
272
- if not clarification_options:
273
- clarification_options = [
274
- {
275
- "code": candidate["code"].upper(),
276
- "title": candidate["title"],
277
- "reason": candidate.get("summary") or candidate.get("section_title") or "",
278
- }
279
- for candidate in canonical_candidates[:3]
280
- ]
281
- print("[WIZARD] Using fallback options (LLM unavailable or failed)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
 
283
- # Thêm option "Khác" nếu chưa có
284
- if not any(opt.get("code") == "__other__" for opt in clarification_options):
285
- clarification_options.append(
286
- {
287
- "code": "__other__",
288
- "title": "Khác",
289
- "reason": "Tôi muốn hỏi văn bản hoặc chủ đề pháp luật khác.",
290
- }
291
- )
292
- response = {
293
- "message": intro_message,
294
  "intent": intent,
295
- "confidence": confidence,
296
  "results": [],
297
  "count": 0,
298
- "routing": "legal_wizard",
299
- "type": "options",
300
- "wizard_stage": "choose_document",
301
- "clarification": {
302
- "message": intro_message,
303
- "options": clarification_options,
304
- },
305
- "options": clarification_options,
306
  }
307
- if session_id:
308
- response["session_id"] = session_id
309
- try:
310
- ConversationContext.add_message(
311
- session_id=session_id,
312
- role="bot",
313
- content=intro_message,
314
- intent=intent,
315
- )
316
- except Exception as e:
317
- print(f"⚠️ Failed to save wizard bot message: {e}")
318
- return response
319
 
320
  # Stage 2: Choose topic/section (if document selected but no topic yet)
321
  # Skip if wizard_stage is already "answer" (user wants final answer)
322
- if intent == "search_legal" and selected_doc_code and not selected_topic and not has_doc_code_in_query and wizard_stage != "answer":
 
 
 
 
 
 
 
323
  print("[WIZARD] ✅ Stage 2 triggered: Choose topic/section")
324
 
325
  # Get document title
 
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"
142
  cached_response["_source"] = cached_response.get("_source", "cache")
 
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
+ disable_wizard_flow = os.environ.get("DISABLE_WIZARD_FLOW", "false").lower() == "true"
171
+ print(f"[WIZARD] DISABLE_WIZARD_FLOW={os.environ.get('DISABLE_WIZARD_FLOW', 'false')} -> disable_wizard_flow={disable_wizard_flow}")
172
 
173
  has_doc_code_in_query = self._query_has_document_code(query)
174
  wizard_stage = session_metadata.get("wizard_stage") if session_metadata else None
 
177
 
178
  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]}'")
179
 
180
+ # CRITICAL: If wizard flow is disabled, reset all wizard state immediately
181
+ if disable_wizard_flow:
182
+ print("[WIZARD] 🚫 Wizard flow DISABLED - resetting all wizard state and skipping wizard stages")
183
+ selected_doc_code = None
184
+ selected_topic = None
185
+ wizard_stage = None
186
+ wizard_depth = 0
187
+ # Update session metadata to clear wizard state
188
+ if session_id:
189
+ try:
190
+ ConversationContext.update_session_metadata(
191
+ session_id,
 
 
 
 
 
192
  {
193
+ "selected_document_code": None,
194
+ "selected_topic": None,
195
+ "wizard_stage": None,
196
+ "wizard_depth": 0,
 
197
  }
198
  )
199
+ print("[WIZARD] Wizard state cleared from session metadata")
200
+ except Exception as e:
201
+ print(f"⚠️ Failed to clear wizard state: {e}")
202
+ # Also update session_metadata dict for current function scope
203
+ if session_metadata:
204
+ session_metadata["selected_document_code"] = None
205
+ session_metadata["selected_topic"] = None
206
+ session_metadata["wizard_stage"] = None
207
+ session_metadata["wizard_depth"] = 0
208
+
209
+ # Reset wizard state if new query doesn't have document code and wizard_stage is "answer"
210
+ # This handles the case where user asks a new question after completing a previous wizard flow
211
+ # CRITICAL: Check conditions and reset BEFORE Stage 1 check
212
+ should_reset = (
213
+ not disable_wizard_flow
214
+ and intent == "search_legal"
215
+ and not has_doc_code_in_query
216
+ and wizard_stage == "answer"
217
+ )
218
+ print(f"[WIZARD] Reset check - intent={intent}, has_doc_code={has_doc_code_in_query}, wizard_stage={wizard_stage}, should_reset={should_reset}") # v2.0-fix
219
+
220
+ if should_reset:
221
+ print("[WIZARD] 🔄 New query detected, resetting wizard state for fresh start")
222
+ selected_doc_code = None
223
+ selected_topic = None
224
+ wizard_stage = None
225
+ # Update session metadata FIRST before continuing
226
+ if session_id:
 
 
 
 
 
 
 
 
 
227
  try:
228
+ ConversationContext.update_session_metadata(
229
+ session_id,
230
+ {
231
+ "selected_document_code": None,
232
+ "selected_topic": None,
233
+ "wizard_stage": None,
234
+ "wizard_depth": 0,
235
+ }
236
  )
237
+ print("[WIZARD] ✅ Wizard state reset in session metadata")
238
+ except Exception as e:
239
+ print(f"⚠️ Failed to reset wizard state: {e}")
240
+ # Also update session_metadata dict for current function scope
241
+ if session_metadata:
242
+ session_metadata["selected_document_code"] = None
243
+ session_metadata["selected_topic"] = None
244
+ session_metadata["wizard_stage"] = None
245
+ session_metadata["wizard_depth"] = 0
246
+
247
+ # Stage 1: Choose document (if no document selected and no code in query)
248
+ # Use Query Rewrite Strategy from slow_path_handler instead of old LLM suggestions
249
+ if (
250
+ intent == "search_legal"
251
+ and not selected_doc_code
252
+ and not has_doc_code_in_query
253
+ and not disable_wizard_flow
254
+ ):
255
+ print("[WIZARD] Stage 1: Using Query Rewrite Strategy from slow_path_handler")
256
+ # Delegate to slow_path_handler which has Query Rewrite Strategy
257
+ slow_handler = SlowPathHandler()
258
+ response = slow_handler.handle(
259
+ query=query,
260
+ intent=intent,
261
+ session_id=session_id,
262
+ selected_document_code=None, # No document selected yet
263
+ )
264
 
265
+ # Ensure response has wizard metadata
266
+ if response:
267
+ response.setdefault("wizard_stage", "choose_document")
268
+ response.setdefault("routing", "legal_wizard")
269
+ response.setdefault("type", "options")
270
+
271
+ # Update session metadata
272
+ if session_id:
273
+ try:
274
+ ConversationContext.update_session_metadata(
275
+ session_id,
276
+ {
277
+ "wizard_stage": "choose_document",
278
+ "wizard_depth": 1,
279
+ }
280
+ )
281
+ except Exception as e:
282
+ logger.warning("[WIZARD] Failed to update session metadata: %s", e)
283
+
284
+ # Save bot message to context
285
+ if session_id:
286
+ try:
287
+ bot_message = response.get("message") or response.get("clarification", {}).get("message", "")
288
+ ConversationContext.add_message(
289
+ session_id=session_id,
290
+ role="bot",
291
+ content=bot_message,
292
+ intent=intent,
293
+ )
294
+ except Exception as e:
295
+ print(f"⚠️ Failed to save wizard bot message: {e}")
296
 
297
+ return response if response else {
298
+ "message": "Xin lỗi, lỗi xảy ra khi tìm kiếm văn bản.",
 
 
 
 
 
 
 
 
 
299
  "intent": intent,
 
300
  "results": [],
301
  "count": 0,
 
 
 
 
 
 
 
 
302
  }
 
 
 
 
 
 
 
 
 
 
 
 
303
 
304
  # Stage 2: Choose topic/section (if document selected but no topic yet)
305
  # Skip if wizard_stage is already "answer" (user wants final answer)
306
+ if (
307
+ intent == "search_legal"
308
+ and selected_doc_code
309
+ and not selected_topic
310
+ and not has_doc_code_in_query
311
+ and wizard_stage != "answer"
312
+ and not disable_wizard_flow
313
+ ):
314
  print("[WIZARD] ✅ Stage 2 triggered: Choose topic/section")
315
 
316
  # Get document title
backend/hue_portal/chatbot/slow_path_handler.py CHANGED
@@ -4,7 +4,8 @@ Slow Path Handler - Full RAG pipeline for complex queries.
4
  import os
5
  import time
6
  import logging
7
- from typing import Dict, Any, Optional, List
 
8
  import unicodedata
9
  import re
10
  from concurrent.futures import ThreadPoolExecutor, Future
@@ -20,12 +21,16 @@ from hue_portal.core.models import (
20
  LegalDocument,
21
  )
22
  from hue_portal.core.search_ml import search_with_ml
 
23
  # Lazy import reranker to avoid blocking startup (FlagEmbedding may download model)
24
  # from hue_portal.core.reranker import rerank_documents
25
  from hue_portal.chatbot.llm_integration import get_llm_generator
26
  from hue_portal.chatbot.structured_legal import format_structured_legal_answer
27
  from hue_portal.chatbot.context_manager import ConversationContext
28
  from hue_portal.chatbot.router import DOCUMENT_CODE_PATTERNS
 
 
 
29
 
30
  logger = logging.getLogger(__name__)
31
 
@@ -38,9 +43,15 @@ class SlowPathHandler:
38
  self.llm_generator = get_llm_generator()
39
  # Thread pool for parallel search (max 2 workers to avoid overwhelming DB)
40
  self._executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="parallel_search")
41
- # Cache for prefetched results by session_id
42
  self._prefetched_cache: Dict[str, Dict[str, Any]] = {}
43
  self._cache_lock = threading.Lock()
 
 
 
 
 
 
44
 
45
  def handle(
46
  self,
@@ -106,71 +117,240 @@ class SlowPathHandler:
106
  )
107
  if (
108
  intent == "search_legal"
 
109
  and not selected_document_code_normalized
110
  and not has_explicit_code
111
  ):
112
- logger.info("[WIZARD] ✅ Wizard conditions met, returning options payload")
113
- canonical_candidates: List[Dict[str, Any]] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  try:
115
- canonical_docs = list(
116
- LegalDocument.objects.filter(
117
- code__in=["264-QD-TW", "QD-69-TW", "TT-02-CAND"]
118
- )
 
 
 
 
 
 
 
 
 
119
  )
120
- for doc in canonical_docs:
121
- summary = getattr(doc, "summary", "") or ""
122
- metadata = getattr(doc, "metadata", {}) or {}
123
- if not summary and isinstance(metadata, dict):
124
- summary = metadata.get("summary", "")
125
- canonical_candidates.append(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  {
127
- "code": doc.code,
128
- "title": getattr(doc, "title", "") or doc.code,
129
- "summary": summary,
130
- "doc_type": getattr(doc, "doc_type", "") or "",
131
- "section_title": "",
132
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  except Exception as exc:
135
- logger.warning(
136
- "[CLARIFICATION] Canonical documents lookup failed, using static list: %s",
137
  exc,
 
138
  )
139
-
140
- if not canonical_candidates:
141
- canonical_candidates = [
142
- {
143
- "code": "264-QD-TW",
144
- "title": "Quyết định 264-QĐ/TW về kỷ luật đảng viên",
145
- "summary": "",
146
- "doc_type": "",
147
- "section_title": "",
148
- },
149
- {
150
- "code": "QD-69-TW",
151
- "title": "Quy định 69-QĐ/TW về kỷ luật tổ chức đảng, đảng viên",
152
- "summary": "",
153
- "doc_type": "",
154
- "section_title": "",
155
- },
156
- {
157
- "code": "TT-02-CAND",
158
- "title": "Thông tư 02/2021/TT-BCA về điều lệnh CAND",
159
- "summary": "",
160
- "doc_type": "",
161
- "section_title": "",
162
- },
163
- ]
164
-
165
- clarification_payload = self._build_clarification_payload(
166
- query, canonical_candidates
167
- )
168
- if clarification_payload:
169
- clarification_payload.setdefault("intent", intent)
170
- clarification_payload.setdefault("_source", "clarification")
171
- clarification_payload.setdefault("routing", "clarification")
172
- clarification_payload.setdefault("confidence", 0.3)
173
- return clarification_payload
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
  # Search based on intent - retrieve top-15 for reranking (balance speed and RAM)
176
  search_result = self._search_by_intent(
@@ -685,6 +865,23 @@ class SlowPathHandler:
685
  keywords[:5],
686
  )
687
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
688
  # Search in the selected document
689
  query_text = " ".join(keywords) if keywords else ""
690
  search_result = self._search_by_intent(
@@ -694,16 +891,27 @@ class SlowPathHandler:
694
  preferred_document_code=document_code.upper(),
695
  )
696
 
697
- # Store in cache
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
698
  with self._cache_lock:
699
  if session_id not in self._prefetched_cache:
700
  self._prefetched_cache[session_id] = {}
701
- self._prefetched_cache[session_id]["document_results"] = {
702
- "document_code": document_code,
703
- "results": search_result.get("results", []),
704
- "count": search_result.get("count", 0),
705
- "timestamp": time.time(),
706
- }
707
 
708
  logger.info(
709
  "[PARALLEL_SEARCH] Completed background search for doc=%s, found %d results",
@@ -905,13 +1113,12 @@ class SlowPathHandler:
905
  )
906
  else:
907
  logger.debug("[SEARCH] No document code detected for query: %s", query)
908
- # Retrieve top-15 for reranking (will be reduced to top-4 after rerank)
909
- search_results = search_with_ml(
 
910
  qs,
911
- keywords,
912
- text_fields,
913
  top_k=limit, # limit=15 for reranking, will be reduced to 4
914
- min_score=0.02, # Lower threshold for legal
915
  )
916
  results = self._format_legal_results(search_results, detected_code, query=query)
917
  logger.info(
 
4
  import os
5
  import time
6
  import logging
7
+ import hashlib
8
+ from typing import Dict, Any, Optional, List, Set
9
  import unicodedata
10
  import re
11
  from concurrent.futures import ThreadPoolExecutor, Future
 
21
  LegalDocument,
22
  )
23
  from hue_portal.core.search_ml import search_with_ml
24
+ from hue_portal.core.pure_semantic_search import pure_semantic_search
25
  # Lazy import reranker to avoid blocking startup (FlagEmbedding may download model)
26
  # from hue_portal.core.reranker import rerank_documents
27
  from hue_portal.chatbot.llm_integration import get_llm_generator
28
  from hue_portal.chatbot.structured_legal import format_structured_legal_answer
29
  from hue_portal.chatbot.context_manager import ConversationContext
30
  from hue_portal.chatbot.router import DOCUMENT_CODE_PATTERNS
31
+ from hue_portal.core.query_rewriter import get_query_rewriter
32
+ from hue_portal.core.pure_semantic_search import pure_semantic_search, parallel_vector_search
33
+ from hue_portal.core.redis_cache import get_redis_cache
34
 
35
  logger = logging.getLogger(__name__)
36
 
 
43
  self.llm_generator = get_llm_generator()
44
  # Thread pool for parallel search (max 2 workers to avoid overwhelming DB)
45
  self._executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="parallel_search")
46
+ # Cache for prefetched results by session_id (in-memory fallback)
47
  self._prefetched_cache: Dict[str, Dict[str, Any]] = {}
48
  self._cache_lock = threading.Lock()
49
+ # Redis cache for prefetch results
50
+ self.redis_cache = get_redis_cache()
51
+ # Prefetch cache TTL (30 minutes default)
52
+ self.prefetch_cache_ttl = int(os.environ.get("CACHE_PREFETCH_TTL", "1800"))
53
+ # Toggle wizard flow (disable to answer directly)
54
+ self.disable_wizard_flow = os.environ.get("DISABLE_WIZARD_FLOW", "false").lower() == "true"
55
 
56
  def handle(
57
  self,
 
117
  )
118
  if (
119
  intent == "search_legal"
120
+ and not self.disable_wizard_flow
121
  and not selected_document_code_normalized
122
  and not has_explicit_code
123
  ):
124
+ logger.info("[QUERY_REWRITE] ✅ Wizard conditions met, using Query Rewrite Strategy")
125
+
126
+ # Query Rewrite Strategy: Rewrite query into 3-5 optimized legal queries
127
+ query_rewriter = get_query_rewriter(self.llm_generator)
128
+
129
+ # Get conversation context for query rewriting
130
+ context = None
131
+ if session_id:
132
+ try:
133
+ recent_messages = ConversationContext.get_recent_messages(session_id, limit=5)
134
+ context = [
135
+ {"role": msg.role, "content": msg.content}
136
+ for msg in recent_messages
137
+ ]
138
+ except Exception as exc:
139
+ logger.warning("[QUERY_REWRITE] Failed to load context: %s", exc)
140
+
141
+ # Rewrite query into 3-5 queries
142
+ rewritten_queries = query_rewriter.rewrite_query(
143
+ query,
144
+ context=context,
145
+ max_queries=5,
146
+ min_queries=3
147
+ )
148
+
149
+ if not rewritten_queries:
150
+ # Fallback to original query if rewrite fails
151
+ rewritten_queries = [query]
152
+
153
+ logger.info(
154
+ "[QUERY_REWRITE] Rewrote query into %d queries: %s",
155
+ len(rewritten_queries),
156
+ rewritten_queries[:3]
157
+ )
158
+
159
+ # Parallel vector search with multiple queries
160
  try:
161
+ from hue_portal.core.models import LegalSection
162
+
163
+ # Search all legal sections (no document filter yet)
164
+ qs = LegalSection.objects.all()
165
+ text_fields = ["section_title", "section_code", "content"]
166
+
167
+ # Use parallel vector search
168
+ search_results = parallel_vector_search(
169
+ rewritten_queries,
170
+ qs,
171
+ top_k_per_query=5,
172
+ final_top_k=7,
173
+ text_fields=text_fields
174
  )
175
+
176
+ # Extract unique document codes from results
177
+ doc_codes_seen: Set[str] = set()
178
+ document_options: List[Dict[str, Any]] = []
179
+
180
+ for section, score in search_results:
181
+ doc = getattr(section, "document", None)
182
+ if not doc:
183
+ continue
184
+
185
+ doc_code = getattr(doc, "code", "").upper()
186
+ if not doc_code or doc_code in doc_codes_seen:
187
+ continue
188
+
189
+ doc_codes_seen.add(doc_code)
190
+
191
+ # Get document metadata
192
+ doc_title = getattr(doc, "title", "") or doc_code
193
+ doc_summary = getattr(doc, "summary", "") or ""
194
+ if not doc_summary:
195
+ metadata = getattr(doc, "metadata", {}) or {}
196
+ if isinstance(metadata, dict):
197
+ doc_summary = metadata.get("summary", "")
198
+
199
+ document_options.append({
200
+ "code": doc_code,
201
+ "title": doc_title,
202
+ "summary": doc_summary,
203
+ "score": float(score),
204
+ "doc_type": getattr(doc, "doc_type", "") or "",
205
+ })
206
+
207
+ # Limit to top 5 documents
208
+ if len(document_options) >= 5:
209
+ break
210
+
211
+ # If no documents found, use canonical fallback
212
+ if not document_options:
213
+ logger.warning("[QUERY_REWRITE] No documents found, using canonical fallback")
214
+ canonical_candidates = [
215
  {
216
+ "code": "264-QD-TW",
217
+ "title": "Quyết định 264-QĐ/TW về kỷ luật đảng viên",
218
+ "summary": "",
219
+ "doc_type": "",
220
+ },
221
+ {
222
+ "code": "QD-69-TW",
223
+ "title": "Quy định 69-QĐ/TW về kỷ luật tổ chức đảng, đảng viên",
224
+ "summary": "",
225
+ "doc_type": "",
226
+ },
227
+ {
228
+ "code": "TT-02-CAND",
229
+ "title": "Thông tư 02/2021/TT-BCA về điều lệnh CAND",
230
+ "summary": "",
231
+ "doc_type": "",
232
+ },
233
+ ]
234
+ clarification_payload = self._build_clarification_payload(
235
+ query, canonical_candidates
236
  )
237
+ if clarification_payload:
238
+ clarification_payload.setdefault("intent", intent)
239
+ clarification_payload.setdefault("_source", "clarification")
240
+ clarification_payload.setdefault("routing", "clarification")
241
+ clarification_payload.setdefault("confidence", 0.3)
242
+ return clarification_payload
243
+
244
+ # Build options from search results
245
+ options = [
246
+ {
247
+ "code": opt["code"],
248
+ "title": opt["title"],
249
+ "reason": opt.get("summary") or f"Độ liên quan: {opt['score']:.2f}",
250
+ }
251
+ for opt in document_options
252
+ ]
253
+
254
+ # Add "Khác" option
255
+ if not any(opt.get("code") == "__other__" for opt in options):
256
+ options.append({
257
+ "code": "__other__",
258
+ "title": "Khác",
259
+ "reason": "Tôi muốn hỏi văn bản hoặc chủ đề pháp luật khác.",
260
+ })
261
+
262
+ message = (
263
+ "Tôi đã tìm thấy các văn bản pháp luật liên quan đến câu hỏi của bạn.\n\n"
264
+ "Bạn hãy chọn văn bản muốn tra cứu để tôi trả lời chi tiết hơn:"
265
+ )
266
+
267
+ logger.info(
268
+ "[QUERY_REWRITE] ✅ Found %d documents using Query Rewrite Strategy",
269
+ len(document_options)
270
+ )
271
+
272
+ return {
273
+ "type": "options",
274
+ "wizard_stage": "choose_document",
275
+ "message": message,
276
+ "options": options,
277
+ "clarification": {
278
+ "message": message,
279
+ "options": options,
280
+ },
281
+ "results": [],
282
+ "count": 0,
283
+ "intent": intent,
284
+ "_source": "query_rewrite",
285
+ "routing": "query_rewrite",
286
+ "confidence": 0.95, # High confidence with Query Rewrite Strategy
287
+ }
288
+
289
  except Exception as exc:
290
+ logger.error(
291
+ "[QUERY_REWRITE] Error in Query Rewrite Strategy: %s, falling back to LLM suggestions",
292
  exc,
293
+ exc_info=True
294
  )
295
+ # Fallback to original LLM-based clarification
296
+ canonical_candidates: List[Dict[str, Any]] = []
297
+ try:
298
+ canonical_docs = list(
299
+ LegalDocument.objects.filter(
300
+ code__in=["264-QD-TW", "QD-69-TW", "TT-02-CAND"]
301
+ )
302
+ )
303
+ for doc in canonical_docs:
304
+ summary = getattr(doc, "summary", "") or ""
305
+ metadata = getattr(doc, "metadata", {}) or {}
306
+ if not summary and isinstance(metadata, dict):
307
+ summary = metadata.get("summary", "")
308
+ canonical_candidates.append(
309
+ {
310
+ "code": doc.code,
311
+ "title": getattr(doc, "title", "") or doc.code,
312
+ "summary": summary,
313
+ "doc_type": getattr(doc, "doc_type", "") or "",
314
+ "section_title": "",
315
+ }
316
+ )
317
+ except Exception as e:
318
+ logger.warning("[CLARIFICATION] Canonical documents lookup failed: %s", e)
319
+
320
+ if not canonical_candidates:
321
+ canonical_candidates = [
322
+ {
323
+ "code": "264-QD-TW",
324
+ "title": "Quyết định 264-QĐ/TW về kỷ luật đảng viên",
325
+ "summary": "",
326
+ "doc_type": "",
327
+ "section_title": "",
328
+ },
329
+ {
330
+ "code": "QD-69-TW",
331
+ "title": "Quy định 69-QĐ/TW về kỷ luật tổ chức đảng, đảng viên",
332
+ "summary": "",
333
+ "doc_type": "",
334
+ "section_title": "",
335
+ },
336
+ {
337
+ "code": "TT-02-CAND",
338
+ "title": "Thông tư 02/2021/TT-BCA về điều lệnh CAND",
339
+ "summary": "",
340
+ "doc_type": "",
341
+ "section_title": "",
342
+ },
343
+ ]
344
+
345
+ clarification_payload = self._build_clarification_payload(
346
+ query, canonical_candidates
347
+ )
348
+ if clarification_payload:
349
+ clarification_payload.setdefault("intent", intent)
350
+ clarification_payload.setdefault("_source", "clarification_fallback")
351
+ clarification_payload.setdefault("routing", "clarification")
352
+ clarification_payload.setdefault("confidence", 0.3)
353
+ return clarification_payload
354
 
355
  # Search based on intent - retrieve top-15 for reranking (balance speed and RAM)
356
  search_result = self._search_by_intent(
 
865
  keywords[:5],
866
  )
867
 
868
+ # Check Redis cache first
869
+ cache_key = f"prefetch:{document_code.upper()}:{hashlib.sha256(' '.join(keywords).encode()).hexdigest()[:16]}"
870
+ cached_result = None
871
+ if self.redis_cache and self.redis_cache.is_available():
872
+ cached_result = self.redis_cache.get(cache_key)
873
+ if cached_result:
874
+ logger.info(
875
+ "[PARALLEL_SEARCH] ✅ Cache hit for doc=%s",
876
+ document_code
877
+ )
878
+ # Store in in-memory cache too
879
+ with self._cache_lock:
880
+ if session_id not in self._prefetched_cache:
881
+ self._prefetched_cache[session_id] = {}
882
+ self._prefetched_cache[session_id]["document_results"] = cached_result
883
+ return
884
+
885
  # Search in the selected document
886
  query_text = " ".join(keywords) if keywords else ""
887
  search_result = self._search_by_intent(
 
891
  preferred_document_code=document_code.upper(),
892
  )
893
 
894
+ # Prepare cache data
895
+ cache_data = {
896
+ "document_code": document_code,
897
+ "results": search_result.get("results", []),
898
+ "count": search_result.get("count", 0),
899
+ "timestamp": time.time(),
900
+ }
901
+
902
+ # Store in Redis cache
903
+ if self.redis_cache and self.redis_cache.is_available():
904
+ self.redis_cache.set(cache_key, cache_data, ttl_seconds=self.prefetch_cache_ttl)
905
+ logger.debug(
906
+ "[PARALLEL_SEARCH] Cached prefetch results (TTL: %ds)",
907
+ self.prefetch_cache_ttl
908
+ )
909
+
910
+ # Store in in-memory cache (fallback)
911
  with self._cache_lock:
912
  if session_id not in self._prefetched_cache:
913
  self._prefetched_cache[session_id] = {}
914
+ self._prefetched_cache[session_id]["document_results"] = cache_data
 
 
 
 
 
915
 
916
  logger.info(
917
  "[PARALLEL_SEARCH] Completed background search for doc=%s, found %d results",
 
1113
  )
1114
  else:
1115
  logger.debug("[SEARCH] No document code detected for query: %s", query)
1116
+ # Use pure semantic search (100% vector, no BM25)
1117
+ search_results = pure_semantic_search(
1118
+ [keywords],
1119
  qs,
 
 
1120
  top_k=limit, # limit=15 for reranking, will be reduced to 4
1121
+ text_fields=text_fields
1122
  )
1123
  results = self._format_legal_results(search_results, detected_code, query=query)
1124
  logger.info(