davidtran999 commited on
Commit
fbf84fe
·
verified ·
1 Parent(s): 8790076

Upload hue_portal/chatbot/slow_path_handler.py with huggingface_hub

Browse files
hue_portal/chatbot/slow_path_handler.py ADDED
@@ -0,0 +1,1388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Slow Path Handler - Full RAG pipeline for complex queries.
3
+ """
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
12
+ import threading
13
+
14
+ from hue_portal.core.chatbot import get_chatbot, RESPONSE_TEMPLATES
15
+ from hue_portal.core.models import (
16
+ Fine,
17
+ Procedure,
18
+ Office,
19
+ Advisory,
20
+ LegalSection,
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
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class SlowPathHandler:
38
+ """Handle Slow Path queries with full RAG pipeline."""
39
+
40
+ def __init__(self):
41
+ self.chatbot = get_chatbot()
42
+ self.llm_generator = get_llm_generator()
43
+ # Thread pool for parallel search (max 2 workers to avoid overwhelming DB)
44
+ self._executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="parallel_search")
45
+ # Cache for prefetched results by session_id (in-memory fallback)
46
+ self._prefetched_cache: Dict[str, Dict[str, Any]] = {}
47
+ self._cache_lock = threading.Lock()
48
+ # Redis cache for prefetch results
49
+ self.redis_cache = get_redis_cache()
50
+ # Prefetch cache TTL (30 minutes default)
51
+ self.prefetch_cache_ttl = int(os.environ.get("CACHE_PREFETCH_TTL", "1800"))
52
+
53
+ def handle(
54
+ self,
55
+ query: str,
56
+ intent: str,
57
+ session_id: Optional[str] = None,
58
+ selected_document_code: Optional[str] = None,
59
+ ) -> Dict[str, Any]:
60
+ """
61
+ Full RAG pipeline:
62
+ 1. Search (hybrid: BM25 + vector)
63
+ 2. Retrieve top 20 documents
64
+ 3. LLM generation with structured output (for legal queries)
65
+ 4. Guardrails validation
66
+ 5. Retry up to 3 times if needed
67
+
68
+ Args:
69
+ query: User query.
70
+ intent: Detected intent.
71
+ session_id: Optional session ID for context.
72
+ selected_document_code: Selected document code from wizard.
73
+
74
+ Returns:
75
+ Response dict with message, intent, results, etc.
76
+ """
77
+ query = query.strip()
78
+ selected_document_code_normalized = (
79
+ selected_document_code.strip().upper() if selected_document_code else None
80
+ )
81
+
82
+ # Handle greetings
83
+ if intent == "greeting":
84
+ query_lower = query.lower().strip()
85
+ query_words = query_lower.split()
86
+ is_simple_greeting = (
87
+ len(query_words) <= 3 and
88
+ any(greeting in query_lower for greeting in ["xin chào", "chào", "hello", "hi"]) and
89
+ not any(kw in query_lower for kw in ["phạt", "mức phạt", "vi phạm", "thủ tục", "hồ sơ", "địa chỉ", "công an", "cảnh báo"])
90
+ )
91
+ if is_simple_greeting:
92
+ return {
93
+ "message": RESPONSE_TEMPLATES["greeting"],
94
+ "intent": "greeting",
95
+ "results": [],
96
+ "count": 0,
97
+ "_source": "slow_path"
98
+ }
99
+
100
+ # Wizard / option-first cho mọi câu hỏi pháp lý chung:
101
+ # Nếu:
102
+ # - intent là search_legal
103
+ # - chưa có selected_document_code trong session
104
+ # - trong câu hỏi không ghi rõ mã văn bản
105
+ # Thì: luôn trả về payload options để người dùng chọn văn bản trước,
106
+ # chưa generate câu trả lời chi tiết.
107
+ has_explicit_code = self._has_explicit_document_code_in_query(query)
108
+ logger.info(
109
+ "[WIZARD] Checking wizard conditions - intent=%s, selected_code=%s, has_explicit_code=%s, query='%s'",
110
+ intent,
111
+ selected_document_code_normalized,
112
+ has_explicit_code,
113
+ query[:50],
114
+ )
115
+ if (
116
+ intent == "search_legal"
117
+ and not selected_document_code_normalized
118
+ and not has_explicit_code
119
+ ):
120
+ logger.info("[QUERY_REWRITE] ✅ Wizard conditions met, using Query Rewrite Strategy")
121
+
122
+ # Query Rewrite Strategy: Rewrite query into 3-5 optimized legal queries
123
+ query_rewriter = get_query_rewriter(self.llm_generator)
124
+
125
+ # Get conversation context for query rewriting
126
+ context = None
127
+ if session_id:
128
+ try:
129
+ recent_messages = ConversationContext.get_recent_messages(session_id, limit=5)
130
+ context = [
131
+ {"role": msg.role, "content": msg.content}
132
+ for msg in recent_messages
133
+ ]
134
+ except Exception as exc:
135
+ logger.warning("[QUERY_REWRITE] Failed to load context: %s", exc)
136
+
137
+ # Rewrite query into 3-5 queries
138
+ rewritten_queries = query_rewriter.rewrite_query(
139
+ query,
140
+ context=context,
141
+ max_queries=5,
142
+ min_queries=3
143
+ )
144
+
145
+ if not rewritten_queries:
146
+ # Fallback to original query if rewrite fails
147
+ rewritten_queries = [query]
148
+
149
+ logger.info(
150
+ "[QUERY_REWRITE] Rewrote query into %d queries: %s",
151
+ len(rewritten_queries),
152
+ rewritten_queries[:3]
153
+ )
154
+
155
+ # Parallel vector search with multiple queries
156
+ try:
157
+ from hue_portal.core.models import LegalSection
158
+
159
+ # Search all legal sections (no document filter yet)
160
+ qs = LegalSection.objects.all()
161
+ text_fields = ["section_title", "section_code", "content"]
162
+
163
+ # Use parallel vector search
164
+ search_results = parallel_vector_search(
165
+ rewritten_queries,
166
+ qs,
167
+ top_k_per_query=5,
168
+ final_top_k=7,
169
+ text_fields=text_fields
170
+ )
171
+
172
+ # Extract unique document codes from results
173
+ doc_codes_seen: Set[str] = set()
174
+ document_options: List[Dict[str, Any]] = []
175
+
176
+ for section, score in search_results:
177
+ doc = getattr(section, "document", None)
178
+ if not doc:
179
+ continue
180
+
181
+ doc_code = getattr(doc, "code", "").upper()
182
+ if not doc_code or doc_code in doc_codes_seen:
183
+ continue
184
+
185
+ doc_codes_seen.add(doc_code)
186
+
187
+ # Get document metadata
188
+ doc_title = getattr(doc, "title", "") or doc_code
189
+ doc_summary = getattr(doc, "summary", "") or ""
190
+ if not doc_summary:
191
+ metadata = getattr(doc, "metadata", {}) or {}
192
+ if isinstance(metadata, dict):
193
+ doc_summary = metadata.get("summary", "")
194
+
195
+ document_options.append({
196
+ "code": doc_code,
197
+ "title": doc_title,
198
+ "summary": doc_summary,
199
+ "score": float(score),
200
+ "doc_type": getattr(doc, "doc_type", "") or "",
201
+ })
202
+
203
+ # Limit to top 5 documents
204
+ if len(document_options) >= 5:
205
+ break
206
+
207
+ # If no documents found, use canonical fallback
208
+ if not document_options:
209
+ logger.warning("[QUERY_REWRITE] No documents found, using canonical fallback")
210
+ canonical_candidates = [
211
+ {
212
+ "code": "264-QD-TW",
213
+ "title": "Quyết định 264-QĐ/TW về kỷ luật đảng viên",
214
+ "summary": "",
215
+ "doc_type": "",
216
+ },
217
+ {
218
+ "code": "QD-69-TW",
219
+ "title": "Quy định 69-QĐ/TW về kỷ luật tổ chức đảng, đảng viên",
220
+ "summary": "",
221
+ "doc_type": "",
222
+ },
223
+ {
224
+ "code": "TT-02-CAND",
225
+ "title": "Thông tư 02/2021/TT-BCA về điều lệnh CAND",
226
+ "summary": "",
227
+ "doc_type": "",
228
+ },
229
+ ]
230
+ clarification_payload = self._build_clarification_payload(
231
+ query, canonical_candidates
232
+ )
233
+ if clarification_payload:
234
+ clarification_payload.setdefault("intent", intent)
235
+ clarification_payload.setdefault("_source", "clarification")
236
+ clarification_payload.setdefault("routing", "clarification")
237
+ clarification_payload.setdefault("confidence", 0.3)
238
+ return clarification_payload
239
+
240
+ # Build options from search results
241
+ options = [
242
+ {
243
+ "code": opt["code"],
244
+ "title": opt["title"],
245
+ "reason": opt.get("summary") or f"Độ liên quan: {opt['score']:.2f}",
246
+ }
247
+ for opt in document_options
248
+ ]
249
+
250
+ # Add "Khác" option
251
+ if not any(opt.get("code") == "__other__" for opt in options):
252
+ options.append({
253
+ "code": "__other__",
254
+ "title": "Khác",
255
+ "reason": "Tôi muốn hỏi văn bản hoặc chủ đề pháp luật khác.",
256
+ })
257
+
258
+ message = (
259
+ "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"
260
+ "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:"
261
+ )
262
+
263
+ logger.info(
264
+ "[QUERY_REWRITE] ✅ Found %d documents using Query Rewrite Strategy",
265
+ len(document_options)
266
+ )
267
+
268
+ return {
269
+ "type": "options",
270
+ "wizard_stage": "choose_document",
271
+ "message": message,
272
+ "options": options,
273
+ "clarification": {
274
+ "message": message,
275
+ "options": options,
276
+ },
277
+ "results": [],
278
+ "count": 0,
279
+ "intent": intent,
280
+ "_source": "query_rewrite",
281
+ "routing": "query_rewrite",
282
+ "confidence": 0.95, # High confidence with Query Rewrite Strategy
283
+ }
284
+
285
+ except Exception as exc:
286
+ logger.error(
287
+ "[QUERY_REWRITE] Error in Query Rewrite Strategy: %s, falling back to LLM suggestions",
288
+ exc,
289
+ exc_info=True
290
+ )
291
+ # Fallback to original LLM-based clarification
292
+ canonical_candidates: List[Dict[str, Any]] = []
293
+ try:
294
+ canonical_docs = list(
295
+ LegalDocument.objects.filter(
296
+ code__in=["264-QD-TW", "QD-69-TW", "TT-02-CAND"]
297
+ )
298
+ )
299
+ for doc in canonical_docs:
300
+ summary = getattr(doc, "summary", "") or ""
301
+ metadata = getattr(doc, "metadata", {}) or {}
302
+ if not summary and isinstance(metadata, dict):
303
+ summary = metadata.get("summary", "")
304
+ canonical_candidates.append(
305
+ {
306
+ "code": doc.code,
307
+ "title": getattr(doc, "title", "") or doc.code,
308
+ "summary": summary,
309
+ "doc_type": getattr(doc, "doc_type", "") or "",
310
+ "section_title": "",
311
+ }
312
+ )
313
+ except Exception as e:
314
+ logger.warning("[CLARIFICATION] Canonical documents lookup failed: %s", e)
315
+
316
+ if not canonical_candidates:
317
+ canonical_candidates = [
318
+ {
319
+ "code": "264-QD-TW",
320
+ "title": "Quyết định 264-QĐ/TW về kỷ luật đảng viên",
321
+ "summary": "",
322
+ "doc_type": "",
323
+ "section_title": "",
324
+ },
325
+ {
326
+ "code": "QD-69-TW",
327
+ "title": "Quy định 69-QĐ/TW về kỷ luật tổ chức đảng, đảng viên",
328
+ "summary": "",
329
+ "doc_type": "",
330
+ "section_title": "",
331
+ },
332
+ {
333
+ "code": "TT-02-CAND",
334
+ "title": "Thông tư 02/2021/TT-BCA về điều lệnh CAND",
335
+ "summary": "",
336
+ "doc_type": "",
337
+ "section_title": "",
338
+ },
339
+ ]
340
+
341
+ clarification_payload = self._build_clarification_payload(
342
+ query, canonical_candidates
343
+ )
344
+ if clarification_payload:
345
+ clarification_payload.setdefault("intent", intent)
346
+ clarification_payload.setdefault("_source", "clarification_fallback")
347
+ clarification_payload.setdefault("routing", "clarification")
348
+ clarification_payload.setdefault("confidence", 0.3)
349
+ return clarification_payload
350
+
351
+ # Search based on intent - retrieve top-15 for reranking (balance speed and RAM)
352
+ search_result = self._search_by_intent(
353
+ intent,
354
+ query,
355
+ limit=15,
356
+ preferred_document_code=selected_document_code_normalized,
357
+ ) # Balance: 15 for good recall, not too slow
358
+
359
+ # Fast path for high-confidence legal queries (skip for complex queries)
360
+ fast_path_response = None
361
+ if intent == "search_legal" and not self._is_complex_query(query):
362
+ fast_path_response = self._maybe_fast_path_response(search_result["results"], query)
363
+ if fast_path_response:
364
+ fast_path_response["intent"] = intent
365
+ fast_path_response["_source"] = "fast_path"
366
+ return fast_path_response
367
+
368
+ # Rerank results - DISABLED for speed (can enable via ENABLE_RERANKER env var)
369
+ # Reranker adds 1-3 seconds delay, skip for faster responses
370
+ enable_reranker = os.environ.get("ENABLE_RERANKER", "false").lower() == "true"
371
+ if intent == "search_legal" and enable_reranker:
372
+ try:
373
+ # Lazy import to avoid blocking startup (FlagEmbedding may download model)
374
+ from hue_portal.core.reranker import rerank_documents
375
+
376
+ legal_results = [r for r in search_result["results"] if r.get("type") == "legal"]
377
+ if len(legal_results) > 0:
378
+ # Rerank to top-4 (balance speed and context quality)
379
+ top_k = min(4, len(legal_results))
380
+ reranked = rerank_documents(query, legal_results, top_k=top_k)
381
+ # Update search_result with reranked results (keep non-legal results)
382
+ non_legal = [r for r in search_result["results"] if r.get("type") != "legal"]
383
+ search_result["results"] = reranked + non_legal
384
+ search_result["count"] = len(search_result["results"])
385
+ logger.info(
386
+ "[RERANKER] Reranked %d legal results to top-%d for query: %s",
387
+ len(legal_results),
388
+ top_k,
389
+ query[:50]
390
+ )
391
+ except Exception as e:
392
+ logger.warning("[RERANKER] Reranking failed: %s, using original results", e)
393
+ elif intent == "search_legal":
394
+ # Skip reranking for speed - just use top results by score
395
+ logger.debug("[RERANKER] Skipped reranking for speed (ENABLE_RERANKER=false)")
396
+
397
+ # BƯỚC 1: Bypass LLM khi có results tốt (tránh context overflow + tăng tốc 30-40%)
398
+ # Chỉ áp dụng cho legal queries có results với score cao
399
+ if intent == "search_legal" and search_result["count"] > 0:
400
+ top_result = search_result["results"][0]
401
+ top_score = top_result.get("score", 0.0) or 0.0
402
+ top_data = top_result.get("data", {})
403
+ doc_code = (top_data.get("document_code") or "").upper()
404
+ content = top_data.get("content", "") or top_data.get("excerpt", "")
405
+
406
+ # Bypass LLM nếu:
407
+ # 1. Có document code (TT-02-CAND, etc.) và content đủ dài
408
+ # 2. Score >= 0.4 (giảm threshold để dễ trigger hơn)
409
+ # 3. Hoặc có keywords quan trọng (%, hạ bậc, thi đua, tỷ lệ) với score >= 0.3
410
+ should_bypass = False
411
+ query_lower = query.lower()
412
+ has_keywords = any(kw in query_lower for kw in ["%", "phần trăm", "tỷ lệ", "12%", "20%", "10%", "hạ bậc", "thi đua", "xếp loại", "vi phạm", "cán bộ"])
413
+
414
+ # Điều kiện bypass dễ hơn: có doc_code + content đủ dài + score hợp lý
415
+ if doc_code and len(content) > 100:
416
+ if top_score >= 0.4:
417
+ should_bypass = True
418
+ elif has_keywords and top_score >= 0.3:
419
+ should_bypass = True
420
+ # Hoặc có keywords quan trọng + content đủ dài
421
+ elif has_keywords and len(content) > 100 and top_score >= 0.3:
422
+ should_bypass = True
423
+
424
+ if should_bypass:
425
+ # Template trả thẳng cho query về tỷ lệ vi phạm + hạ bậc thi đua
426
+ if any(kw in query_lower for kw in ["12%", "tỷ lệ", "phần trăm", "hạ bậc", "thi đua"]):
427
+ # Query về tỷ lệ vi phạm và hạ bậc thi đua
428
+ section_code = top_data.get("section_code", "")
429
+ section_title = top_data.get("section_title", "")
430
+ doc_title = top_data.get("document_title", "văn bản pháp luật")
431
+
432
+ # Trích xuất đoạn liên quan từ content
433
+ content_preview = content[:600] + "..." if len(content) > 600 else content
434
+
435
+ answer = (
436
+ f"Theo {doc_title} ({doc_code}):\n\n"
437
+ f"{section_code}: {section_title}\n\n"
438
+ f"{content_preview}\n\n"
439
+ f"Nguồn: {section_code}, {doc_title} ({doc_code})"
440
+ )
441
+ else:
442
+ # Template chung cho legal queries
443
+ section_code = top_data.get("section_code", "Điều liên quan")
444
+ section_title = top_data.get("section_title", "")
445
+ doc_title = top_data.get("document_title", "văn bản pháp luật")
446
+ content_preview = content[:500] + "..." if len(content) > 500 else content
447
+
448
+ answer = (
449
+ f"Kết quả chính xác nhất:\n\n"
450
+ f"- Văn bản: {doc_title} ({doc_code})\n"
451
+ f"- Điều khoản: {section_code}" + (f" – {section_title}" if section_title else "") + "\n\n"
452
+ f"{content_preview}\n\n"
453
+ f"Nguồn: {section_code}, {doc_title} ({doc_code})"
454
+ )
455
+
456
+ logger.info(
457
+ "[BYPASS_LLM] Using raw template for legal query (score=%.3f, doc=%s, query='%s')",
458
+ top_score,
459
+ doc_code,
460
+ query[:50]
461
+ )
462
+
463
+ return {
464
+ "message": answer,
465
+ "intent": intent,
466
+ "confidence": min(0.99, top_score + 0.05),
467
+ "results": search_result["results"][:3],
468
+ "count": min(3, search_result["count"]),
469
+ "_source": "raw_template",
470
+ "routing": "raw_template"
471
+ }
472
+
473
+ # Get conversation context if available
474
+ context = None
475
+ context_summary = ""
476
+ if session_id:
477
+ try:
478
+ recent_messages = ConversationContext.get_recent_messages(session_id, limit=5)
479
+ context = [
480
+ {
481
+ "role": msg.role,
482
+ "content": msg.content,
483
+ "intent": msg.intent
484
+ }
485
+ for msg in recent_messages
486
+ ]
487
+ # Tạo context summary để đưa vào prompt nếu có conversation history
488
+ if len(context) > 1:
489
+ context_parts = []
490
+ for msg in reversed(context[-3:]): # Chỉ lấy 3 message gần nhất
491
+ if msg["role"] == "user":
492
+ context_parts.append(f"Người dùng: {msg['content'][:100]}")
493
+ elif msg["role"] == "bot":
494
+ context_parts.append(f"Bot: {msg['content'][:100]}")
495
+ if context_parts:
496
+ context_summary = "\n\nNgữ cảnh cuộc trò chuyện trước đó:\n" + "\n".join(context_parts)
497
+ except Exception as exc:
498
+ logger.warning("[CONTEXT] Failed to load conversation context: %s", exc)
499
+
500
+ # Enhance query with context if available
501
+ enhanced_query = query
502
+ if context_summary:
503
+ enhanced_query = query + context_summary
504
+
505
+ # Generate response message using LLM if available and we have documents
506
+ message = None
507
+ if self.llm_generator and search_result["count"] > 0:
508
+ # For legal queries, use structured output (top-4 for good context and speed)
509
+ if intent == "search_legal" and search_result["results"]:
510
+ legal_docs = [r["data"] for r in search_result["results"] if r.get("type") == "legal"][:4] # Top-4 for balance
511
+ if legal_docs:
512
+ structured_answer = self.llm_generator.generate_structured_legal_answer(
513
+ enhanced_query, # Dùng enhanced_query có context
514
+ legal_docs,
515
+ prefill_summary=None
516
+ )
517
+ if structured_answer:
518
+ message = format_structured_legal_answer(structured_answer)
519
+
520
+ # For other intents or if structured failed, use regular LLM generation
521
+ if not message:
522
+ documents = [r["data"] for r in search_result["results"][:4]] # Top-4 for balance
523
+ message = self.llm_generator.generate_answer(
524
+ enhanced_query, # Dùng enhanced_query có context
525
+ context=context,
526
+ documents=documents
527
+ )
528
+
529
+ # Fallback to template if LLM not available or failed
530
+ if not message:
531
+ if search_result["count"] > 0:
532
+ # Đặc biệt xử lý legal queries: format tốt hơn thay vì dùng template chung
533
+ if intent == "search_legal" and search_result["results"]:
534
+ top_result = search_result["results"][0]
535
+ top_data = top_result.get("data", {})
536
+ doc_code = top_data.get("document_code", "")
537
+ doc_title = top_data.get("document_title", "văn bản pháp luật")
538
+ section_code = top_data.get("section_code", "")
539
+ section_title = top_data.get("section_title", "")
540
+ content = top_data.get("content", "") or top_data.get("excerpt", "")
541
+
542
+ if content and len(content) > 50:
543
+ content_preview = content[:400] + "..." if len(content) > 400 else content
544
+ message = (
545
+ f"Tôi tìm thấy {search_result['count']} điều khoản liên quan đến '{query}':\n\n"
546
+ f"**{section_code}**: {section_title or 'Nội dung liên quan'}\n\n"
547
+ f"{content_preview}\n\n"
548
+ f"Nguồn: {doc_title}" + (f" ({doc_code})" if doc_code else "")
549
+ )
550
+ else:
551
+ template = RESPONSE_TEMPLATES.get(intent, RESPONSE_TEMPLATES["general_query"])
552
+ message = template.format(
553
+ count=search_result["count"],
554
+ query=query
555
+ )
556
+ else:
557
+ template = RESPONSE_TEMPLATES.get(intent, RESPONSE_TEMPLATES["general_query"])
558
+ message = template.format(
559
+ count=search_result["count"],
560
+ query=query
561
+ )
562
+ else:
563
+ message = RESPONSE_TEMPLATES["no_results"].format(query=query)
564
+
565
+ # Limit results to top 5 for response
566
+ results = search_result["results"][:5]
567
+
568
+ response = {
569
+ "message": message,
570
+ "intent": intent,
571
+ "confidence": 0.95, # High confidence for Slow Path (thorough search)
572
+ "results": results,
573
+ "count": len(results),
574
+ "_source": "slow_path"
575
+ }
576
+
577
+ return response
578
+
579
+ def _maybe_request_clarification(
580
+ self,
581
+ query: str,
582
+ search_result: Dict[str, Any],
583
+ selected_document_code: Optional[str] = None,
584
+ ) -> Optional[Dict[str, Any]]:
585
+ """
586
+ Quyết định có nên hỏi người dùng chọn văn bản (wizard step: choose_document).
587
+
588
+ Nguyên tắc option-first:
589
+ - Nếu user CHƯA chọn văn bản trong session
590
+ - Và trong câu hỏi KHÔNG ghi rõ mã văn bản
591
+ - Và search có trả về kết quả
592
+ => Ưu tiên trả về danh sách văn bản để người dùng chọn, thay vì trả lời thẳng.
593
+ """
594
+ if selected_document_code:
595
+ return None
596
+ if not search_result or search_result.get("count", 0) == 0:
597
+ return None
598
+
599
+ # Nếu người dùng đã ghi rõ mã văn bản trong câu hỏi (ví dụ: 264/QĐ-TW)
600
+ # thì không cần hỏi lại – ưu tiên dùng chính mã đó.
601
+ if self._has_explicit_document_code_in_query(query):
602
+ return None
603
+
604
+ # Ưu tiên dùng danh sách văn bản "chuẩn" (canonical) nếu có trong DB.
605
+ # Tuy nhiên, để đảm bảo wizard luôn hoạt động (option-first),
606
+ # nếu DB chưa đủ dữ liệu thì vẫn build danh sách tĩnh fallback.
607
+ fallback_candidates: List[Dict[str, Any]] = []
608
+ try:
609
+ fallback_docs = list(
610
+ LegalDocument.objects.filter(
611
+ code__in=["264-QD-TW", "QD-69-TW", "TT-02-CAND"]
612
+ )
613
+ )
614
+ for doc in fallback_docs:
615
+ summary = getattr(doc, "summary", "") or ""
616
+ metadata = getattr(doc, "metadata", {}) or {}
617
+ if not summary and isinstance(metadata, dict):
618
+ summary = metadata.get("summary", "")
619
+ fallback_candidates.append(
620
+ {
621
+ "code": doc.code,
622
+ "title": getattr(doc, "title", "") or doc.code,
623
+ "summary": summary,
624
+ "doc_type": getattr(doc, "doc_type", "") or "",
625
+ "section_title": "",
626
+ }
627
+ )
628
+ except Exception as exc:
629
+ logger.warning(
630
+ "[CLARIFICATION] Fallback documents lookup failed, using static list: %s",
631
+ exc,
632
+ )
633
+
634
+ # Nếu DB chưa có đủ thông tin, luôn cung cấp danh sách tĩnh tối thiểu,
635
+ # để wizard option-first vẫn hoạt động.
636
+ if not fallback_candidates:
637
+ fallback_candidates = [
638
+ {
639
+ "code": "264-QD-TW",
640
+ "title": "Quyết định 264-QĐ/TW về kỷ luật đảng viên",
641
+ "summary": "",
642
+ "doc_type": "",
643
+ "section_title": "",
644
+ },
645
+ {
646
+ "code": "QD-69-TW",
647
+ "title": "Quy định 69-QĐ/TW về kỷ luật tổ chức đảng, đảng viên",
648
+ "summary": "",
649
+ "doc_type": "",
650
+ "section_title": "",
651
+ },
652
+ {
653
+ "code": "TT-02-CAND",
654
+ "title": "Thông tư 02/2021/TT-BCA về điều lệnh CAND",
655
+ "summary": "",
656
+ "doc_type": "",
657
+ "section_title": "",
658
+ },
659
+ ]
660
+
661
+ payload = self._build_clarification_payload(query, fallback_candidates)
662
+ if payload:
663
+ logger.info(
664
+ "[CLARIFICATION] Requesting user choice among canonical documents: %s",
665
+ [c["code"] for c in fallback_candidates],
666
+ )
667
+ return payload
668
+
669
+ def _has_explicit_document_code_in_query(self, query: str) -> bool:
670
+ """
671
+ Check if the raw query string explicitly contains a known document code
672
+ pattern (e.g. '264/QĐ-TW', 'QD-69-TW', 'TT-02-CAND').
673
+
674
+ Khác với _detect_document_code (dò toàn bộ bảng LegalDocument theo token),
675
+ hàm này chỉ dựa trên các regex cố định để tránh over-detect cho câu hỏi
676
+ chung chung như 'xử lí kỷ luật đảng viên thế nào'.
677
+ """
678
+ normalized = self._remove_accents(query).upper()
679
+ if not normalized:
680
+ return False
681
+ for pattern in DOCUMENT_CODE_PATTERNS:
682
+ try:
683
+ if re.search(pattern, normalized):
684
+ return True
685
+ except re.error:
686
+ # Nếu pattern không hợp lệ thì bỏ qua, không chặn flow
687
+ continue
688
+ return False
689
+
690
+ def _collect_document_candidates(
691
+ self,
692
+ legal_results: List[Dict[str, Any]],
693
+ limit: int = 4,
694
+ ) -> List[Dict[str, Any]]:
695
+ """Collect unique document candidates from legal results."""
696
+ ordered_codes: List[str] = []
697
+ seen: set[str] = set()
698
+ for result in legal_results:
699
+ data = result.get("data", {})
700
+ code = (data.get("document_code") or "").strip()
701
+ if not code:
702
+ continue
703
+ upper = code.upper()
704
+ if upper in seen:
705
+ continue
706
+ ordered_codes.append(code)
707
+ seen.add(upper)
708
+ if len(ordered_codes) >= limit:
709
+ break
710
+ if len(ordered_codes) < 2:
711
+ return []
712
+ try:
713
+ documents = {
714
+ doc.code.upper(): doc
715
+ for doc in LegalDocument.objects.filter(code__in=ordered_codes)
716
+ }
717
+ except Exception as exc:
718
+ logger.warning("[CLARIFICATION] Unable to load documents for candidates: %s", exc)
719
+ documents = {}
720
+ candidates: List[Dict[str, Any]] = []
721
+ for code in ordered_codes:
722
+ upper = code.upper()
723
+ doc_obj = documents.get(upper)
724
+ section = next(
725
+ (
726
+ res
727
+ for res in legal_results
728
+ if (res.get("data", {}).get("document_code") or "").strip().upper() == upper
729
+ ),
730
+ None,
731
+ )
732
+ data = section.get("data", {}) if section else {}
733
+ summary = ""
734
+ if doc_obj:
735
+ summary = doc_obj.summary or ""
736
+ if not summary and isinstance(doc_obj.metadata, dict):
737
+ summary = doc_obj.metadata.get("summary", "")
738
+ if not summary:
739
+ summary = data.get("excerpt") or data.get("content", "")[:200]
740
+ candidates.append(
741
+ {
742
+ "code": code,
743
+ "title": data.get("document_title") or (doc_obj.title if doc_obj else code),
744
+ "summary": summary,
745
+ "doc_type": doc_obj.doc_type if doc_obj else "",
746
+ "section_title": data.get("section_title") or "",
747
+ }
748
+ )
749
+ return candidates
750
+
751
+ def _build_clarification_payload(
752
+ self,
753
+ query: str,
754
+ candidates: List[Dict[str, Any]],
755
+ ) -> Optional[Dict[str, Any]]:
756
+ if not candidates:
757
+ return None
758
+ default_message = (
759
+ "Tôi tìm thấy một số văn bản có thể phù hợp. "
760
+ "Bạn vui lòng chọn văn bản muốn tra cứu để tôi trả lời chính xác hơn."
761
+ )
762
+ llm_payload = self._call_clarification_llm(query, candidates)
763
+ message = default_message
764
+ options: List[Dict[str, Any]] = []
765
+
766
+ # Ưu tiên dùng gợi ý từ LLM, nhưng phải luôn đảm bảo có options fallback
767
+ if llm_payload:
768
+ message = llm_payload.get("message") or default_message
769
+ raw_options = llm_payload.get("options")
770
+ if isinstance(raw_options, list):
771
+ options = [
772
+ {
773
+ "code": (opt.get("code") or candidate.get("code", "")).upper(),
774
+ "title": opt.get("title") or opt.get("document_title") or candidate.get("title", ""),
775
+ "reason": opt.get("reason")
776
+ or opt.get("summary")
777
+ or candidate.get("summary")
778
+ or candidate.get("section_title")
779
+ or "",
780
+ }
781
+ for opt, candidate in zip(
782
+ raw_options,
783
+ candidates[: len(raw_options)],
784
+ )
785
+ if (opt.get("code") or candidate.get("code"))
786
+ and (opt.get("title") or opt.get("document_title") or candidate.get("title"))
787
+ ]
788
+
789
+ # Nếu LLM không trả về options hợp lệ → fallback build từ candidates
790
+ if not options:
791
+ options = [
792
+ {
793
+ "code": candidate["code"].upper(),
794
+ "title": candidate["title"],
795
+ "reason": candidate.get("summary") or candidate.get("section_title") or "",
796
+ }
797
+ for candidate in candidates[:3]
798
+ ]
799
+ if not any(opt.get("code") == "__other__" for opt in options):
800
+ options.append(
801
+ {
802
+ "code": "__other__",
803
+ "title": "Khác",
804
+ "reason": "Tôi muốn hỏi văn bản hoặc chủ đề khác",
805
+ }
806
+ )
807
+ return {
808
+ # Wizard-style payload: ưu tiên dạng options cho UI
809
+ "type": "options",
810
+ "wizard_stage": "choose_document",
811
+ "message": message,
812
+ "options": options,
813
+ "clarification": {
814
+ "message": message,
815
+ "options": options,
816
+ },
817
+ "results": [],
818
+ "count": 0,
819
+ }
820
+
821
+ def _call_clarification_llm(
822
+ self,
823
+ query: str,
824
+ candidates: List[Dict[str, Any]],
825
+ ) -> Optional[Dict[str, Any]]:
826
+ if not self.llm_generator:
827
+ return None
828
+ try:
829
+ return self.llm_generator.suggest_clarification_topics(
830
+ query,
831
+ candidates,
832
+ max_options=3,
833
+ )
834
+ except Exception as exc:
835
+ logger.warning("[CLARIFICATION] LLM suggestion failed: %s", exc)
836
+ return None
837
+
838
+ def _parallel_search_prepare(
839
+ self,
840
+ document_code: str,
841
+ keywords: List[str],
842
+ session_id: Optional[str] = None,
843
+ ) -> None:
844
+ """
845
+ Trigger parallel search in background when user selects a document option.
846
+ Stores results in cache for Stage 2 (choose topic).
847
+
848
+ Args:
849
+ document_code: Selected document code
850
+ keywords: Keywords extracted from query/options
851
+ session_id: Session ID for caching results
852
+ """
853
+ if not session_id:
854
+ return
855
+
856
+ def _search_task():
857
+ try:
858
+ logger.info(
859
+ "[PARALLEL_SEARCH] Starting background search for doc=%s, keywords=%s",
860
+ document_code,
861
+ keywords[:5],
862
+ )
863
+
864
+ # Check Redis cache first
865
+ cache_key = f"prefetch:{document_code.upper()}:{hashlib.sha256(' '.join(keywords).encode()).hexdigest()[:16]}"
866
+ cached_result = None
867
+ if self.redis_cache and self.redis_cache.is_available():
868
+ cached_result = self.redis_cache.get(cache_key)
869
+ if cached_result:
870
+ logger.info(
871
+ "[PARALLEL_SEARCH] ✅ Cache hit for doc=%s",
872
+ document_code
873
+ )
874
+ # Store in in-memory cache too
875
+ with self._cache_lock:
876
+ if session_id not in self._prefetched_cache:
877
+ self._prefetched_cache[session_id] = {}
878
+ self._prefetched_cache[session_id]["document_results"] = cached_result
879
+ return
880
+
881
+ # Search in the selected document
882
+ query_text = " ".join(keywords) if keywords else ""
883
+ search_result = self._search_by_intent(
884
+ intent="search_legal",
885
+ query=query_text,
886
+ limit=20, # Get more results for topic options
887
+ preferred_document_code=document_code.upper(),
888
+ )
889
+
890
+ # Prepare cache data
891
+ cache_data = {
892
+ "document_code": document_code,
893
+ "results": search_result.get("results", []),
894
+ "count": search_result.get("count", 0),
895
+ "timestamp": time.time(),
896
+ }
897
+
898
+ # Store in Redis cache
899
+ if self.redis_cache and self.redis_cache.is_available():
900
+ self.redis_cache.set(cache_key, cache_data, ttl_seconds=self.prefetch_cache_ttl)
901
+ logger.debug(
902
+ "[PARALLEL_SEARCH] Cached prefetch results (TTL: %ds)",
903
+ self.prefetch_cache_ttl
904
+ )
905
+
906
+ # Store in in-memory cache (fallback)
907
+ with self._cache_lock:
908
+ if session_id not in self._prefetched_cache:
909
+ self._prefetched_cache[session_id] = {}
910
+ self._prefetched_cache[session_id]["document_results"] = cache_data
911
+
912
+ logger.info(
913
+ "[PARALLEL_SEARCH] Completed background search for doc=%s, found %d results",
914
+ document_code,
915
+ search_result.get("count", 0),
916
+ )
917
+ except Exception as exc:
918
+ logger.warning("[PARALLEL_SEARCH] Background search failed: %s", exc)
919
+
920
+ # Submit to thread pool
921
+ self._executor.submit(_search_task)
922
+
923
+ def _parallel_search_topic(
924
+ self,
925
+ document_code: str,
926
+ topic_keywords: List[str],
927
+ session_id: Optional[str] = None,
928
+ ) -> None:
929
+ """
930
+ Trigger parallel search when user selects a topic option.
931
+ Stores results for final answer generation.
932
+
933
+ Args:
934
+ document_code: Selected document code
935
+ topic_keywords: Keywords from selected topic
936
+ session_id: Session ID for caching results
937
+ """
938
+ if not session_id:
939
+ return
940
+
941
+ def _search_task():
942
+ try:
943
+ logger.info(
944
+ "[PARALLEL_SEARCH] Starting topic search for doc=%s, keywords=%s",
945
+ document_code,
946
+ topic_keywords[:5],
947
+ )
948
+
949
+ # Search with topic keywords
950
+ query_text = " ".join(topic_keywords) if topic_keywords else ""
951
+ search_result = self._search_by_intent(
952
+ intent="search_legal",
953
+ query=query_text,
954
+ limit=10,
955
+ preferred_document_code=document_code.upper(),
956
+ )
957
+
958
+ # Store in cache
959
+ with self._cache_lock:
960
+ if session_id not in self._prefetched_cache:
961
+ self._prefetched_cache[session_id] = {}
962
+ self._prefetched_cache[session_id]["topic_results"] = {
963
+ "document_code": document_code,
964
+ "keywords": topic_keywords,
965
+ "results": search_result.get("results", []),
966
+ "count": search_result.get("count", 0),
967
+ "timestamp": time.time(),
968
+ }
969
+
970
+ logger.info(
971
+ "[PARALLEL_SEARCH] Completed topic search, found %d results",
972
+ search_result.get("count", 0),
973
+ )
974
+ except Exception as exc:
975
+ logger.warning("[PARALLEL_SEARCH] Topic search failed: %s", exc)
976
+
977
+ # Submit to thread pool
978
+ self._executor.submit(_search_task)
979
+
980
+ def _get_prefetched_results(
981
+ self,
982
+ session_id: Optional[str],
983
+ result_type: str = "document_results",
984
+ ) -> Optional[Dict[str, Any]]:
985
+ """
986
+ Get prefetched search results from cache.
987
+
988
+ Args:
989
+ session_id: Session ID
990
+ result_type: "document_results" or "topic_results"
991
+
992
+ Returns:
993
+ Cached results dict or None
994
+ """
995
+ if not session_id:
996
+ return None
997
+
998
+ with self._cache_lock:
999
+ cache_entry = self._prefetched_cache.get(session_id)
1000
+ if not cache_entry:
1001
+ return None
1002
+
1003
+ results = cache_entry.get(result_type)
1004
+ if not results:
1005
+ return None
1006
+
1007
+ # Check if results are still fresh (within 5 minutes)
1008
+ timestamp = results.get("timestamp", 0)
1009
+ if time.time() - timestamp > 300: # 5 minutes
1010
+ logger.debug("[PARALLEL_SEARCH] Prefetched results expired for session=%s", session_id)
1011
+ return None
1012
+
1013
+ return results
1014
+
1015
+ def _clear_prefetched_cache(self, session_id: Optional[str]) -> None:
1016
+ """Clear prefetched cache for a session."""
1017
+ if not session_id:
1018
+ return
1019
+
1020
+ with self._cache_lock:
1021
+ if session_id in self._prefetched_cache:
1022
+ del self._prefetched_cache[session_id]
1023
+ logger.debug("[PARALLEL_SEARCH] Cleared cache for session=%s", session_id)
1024
+
1025
+ def _search_by_intent(
1026
+ self,
1027
+ intent: str,
1028
+ query: str,
1029
+ limit: int = 5,
1030
+ preferred_document_code: Optional[str] = None,
1031
+ ) -> Dict[str, Any]:
1032
+ """Search based on classified intent. Reduced limit from 20 to 5 for faster inference on free tier."""
1033
+ # Use original query for better matching
1034
+ keywords = query.strip()
1035
+ extracted = " ".join(self.chatbot.extract_keywords(query))
1036
+ if extracted and len(extracted) > 2:
1037
+ keywords = f"{keywords} {extracted}"
1038
+
1039
+ results = []
1040
+
1041
+ if intent == "search_fine":
1042
+ qs = Fine.objects.all()
1043
+ text_fields = ["name", "code", "article", "decree", "remedial"]
1044
+ search_results = search_with_ml(qs, keywords, text_fields, top_k=limit, min_score=0.1)
1045
+ results = [{"type": "fine", "data": {
1046
+ "id": f.id,
1047
+ "name": f.name,
1048
+ "code": f.code,
1049
+ "min_fine": float(f.min_fine) if f.min_fine else None,
1050
+ "max_fine": float(f.max_fine) if f.max_fine else None,
1051
+ "article": f.article,
1052
+ "decree": f.decree,
1053
+ }} for f in search_results]
1054
+
1055
+ elif intent == "search_procedure":
1056
+ qs = Procedure.objects.all()
1057
+ text_fields = ["title", "domain", "conditions", "dossier"]
1058
+ search_results = search_with_ml(qs, keywords, text_fields, top_k=limit, min_score=0.1)
1059
+ results = [{"type": "procedure", "data": {
1060
+ "id": p.id,
1061
+ "title": p.title,
1062
+ "domain": p.domain,
1063
+ "level": p.level,
1064
+ }} for p in search_results]
1065
+
1066
+ elif intent == "search_office":
1067
+ qs = Office.objects.all()
1068
+ text_fields = ["unit_name", "address", "district", "service_scope"]
1069
+ search_results = search_with_ml(qs, keywords, text_fields, top_k=limit, min_score=0.1)
1070
+ results = [{"type": "office", "data": {
1071
+ "id": o.id,
1072
+ "unit_name": o.unit_name,
1073
+ "address": o.address,
1074
+ "district": o.district,
1075
+ "phone": o.phone,
1076
+ "working_hours": o.working_hours,
1077
+ }} for o in search_results]
1078
+
1079
+ elif intent == "search_advisory":
1080
+ qs = Advisory.objects.all()
1081
+ text_fields = ["title", "summary"]
1082
+ search_results = search_with_ml(qs, keywords, text_fields, top_k=limit, min_score=0.1)
1083
+ results = [{"type": "advisory", "data": {
1084
+ "id": a.id,
1085
+ "title": a.title,
1086
+ "summary": a.summary,
1087
+ }} for a in search_results]
1088
+
1089
+ elif intent == "search_legal":
1090
+ qs = LegalSection.objects.all()
1091
+ text_fields = ["section_title", "section_code", "content"]
1092
+ detected_code = self._detect_document_code(query)
1093
+ effective_code = preferred_document_code or detected_code
1094
+ filtered = False
1095
+ if effective_code:
1096
+ filtered_qs = qs.filter(document__code__iexact=effective_code)
1097
+ if filtered_qs.exists():
1098
+ qs = filtered_qs
1099
+ filtered = True
1100
+ logger.info(
1101
+ "[SEARCH] Prefiltering legal sections for document code %s (query='%s')",
1102
+ effective_code,
1103
+ query,
1104
+ )
1105
+ else:
1106
+ logger.info(
1107
+ "[SEARCH] Document code %s detected but no sections found locally, falling back to full corpus",
1108
+ effective_code,
1109
+ )
1110
+ else:
1111
+ logger.debug("[SEARCH] No document code detected for query: %s", query)
1112
+ # Use pure semantic search (100% vector, no BM25)
1113
+ search_results = pure_semantic_search(
1114
+ [keywords],
1115
+ qs,
1116
+ top_k=limit, # limit=15 for reranking, will be reduced to 4
1117
+ text_fields=text_fields
1118
+ )
1119
+ results = self._format_legal_results(search_results, detected_code, query=query)
1120
+ logger.info(
1121
+ "[SEARCH] Legal intent processed (query='%s', code=%s, filtered=%s, results=%d)",
1122
+ query,
1123
+ detected_code or "None",
1124
+ filtered,
1125
+ len(results),
1126
+ )
1127
+
1128
+ return {
1129
+ "intent": intent,
1130
+ "query": query,
1131
+ "keywords": keywords,
1132
+ "results": results,
1133
+ "count": len(results),
1134
+ "detected_code": detected_code,
1135
+ }
1136
+
1137
+ def _should_save_to_golden(self, query: str, response: Dict) -> bool:
1138
+ """
1139
+ Decide if response should be saved to golden dataset.
1140
+
1141
+ Criteria:
1142
+ - High confidence (>0.95)
1143
+ - Has results
1144
+ - Response is complete and well-formed
1145
+ - Not already in golden dataset
1146
+ """
1147
+ try:
1148
+ from hue_portal.core.models import GoldenQuery
1149
+
1150
+ # Check if already exists
1151
+ query_normalized = self._normalize_query(query)
1152
+ if GoldenQuery.objects.filter(query_normalized=query_normalized, is_active=True).exists():
1153
+ return False
1154
+
1155
+ # Check criteria
1156
+ has_results = response.get("count", 0) > 0
1157
+ has_message = bool(response.get("message", "").strip())
1158
+ confidence = response.get("confidence", 0.0)
1159
+
1160
+ # Only save if high quality
1161
+ if has_results and has_message and confidence >= 0.95:
1162
+ # Additional check: message should be substantial (not just template)
1163
+ message = response.get("message", "")
1164
+ if len(message) > 50: # Substantial response
1165
+ return True
1166
+
1167
+ return False
1168
+ except Exception as e:
1169
+ logger.warning(f"Error checking if should save to golden: {e}")
1170
+ return False
1171
+
1172
+ def _normalize_query(self, query: str) -> str:
1173
+ """Normalize query for matching."""
1174
+ normalized = query.lower().strip()
1175
+ # Remove accents
1176
+ normalized = unicodedata.normalize("NFD", normalized)
1177
+ normalized = "".join(ch for ch in normalized if unicodedata.category(ch) != "Mn")
1178
+ # Remove extra spaces
1179
+ normalized = re.sub(r'\s+', ' ', normalized).strip()
1180
+ return normalized
1181
+
1182
+ def _detect_document_code(self, query: str) -> Optional[str]:
1183
+ """Detect known document code mentioned in the query."""
1184
+ normalized_query = self._remove_accents(query).upper()
1185
+ if not normalized_query:
1186
+ return None
1187
+ try:
1188
+ codes = LegalDocument.objects.values_list("code", flat=True)
1189
+ except Exception as exc:
1190
+ logger.debug("Unable to fetch document codes: %s", exc)
1191
+ return None
1192
+
1193
+ for code in codes:
1194
+ if not code:
1195
+ continue
1196
+ tokens = self._split_code_tokens(code)
1197
+ if tokens and all(token in normalized_query for token in tokens):
1198
+ logger.info("[SEARCH] Detected document code %s in query", code)
1199
+ return code
1200
+ return None
1201
+
1202
+ def _split_code_tokens(self, code: str) -> List[str]:
1203
+ """Split a document code into uppercase accentless tokens."""
1204
+ normalized = self._remove_accents(code).upper()
1205
+ return [tok for tok in re.split(r"[-/\s]+", normalized) if tok]
1206
+
1207
+ def _remove_accents(self, text: str) -> str:
1208
+ if not text:
1209
+ return ""
1210
+ normalized = unicodedata.normalize("NFD", text)
1211
+ return "".join(ch for ch in normalized if unicodedata.category(ch) != "Mn")
1212
+
1213
+ def _format_legal_results(
1214
+ self,
1215
+ search_results: List[Any],
1216
+ detected_code: Optional[str],
1217
+ query: Optional[str] = None,
1218
+ ) -> List[Dict[str, Any]]:
1219
+ """Build legal result payload and apply ordering/boosting based on doc code and keywords."""
1220
+ entries: List[Dict[str, Any]] = []
1221
+ upper_detected = detected_code.upper() if detected_code else None
1222
+
1223
+ # Keywords that indicate important legal concepts (boost score if found)
1224
+ important_keywords = []
1225
+ if query:
1226
+ query_lower = query.lower()
1227
+ # Keywords for percentage/threshold queries
1228
+ if any(kw in query_lower for kw in ["%", "phần trăm", "tỷ lệ", "12%", "20%", "10%"]):
1229
+ important_keywords.extend(["%", "phần trăm", "tỷ lệ", "12", "20", "10"])
1230
+ # Keywords for ranking/demotion queries
1231
+ if any(kw in query_lower for kw in ["hạ bậc", "thi đua", "xếp loại", "đánh giá"]):
1232
+ important_keywords.extend(["hạ bậc", "thi đua", "xếp loại", "đánh giá"])
1233
+
1234
+ for ls in search_results:
1235
+ doc = ls.document
1236
+ doc_code = doc.code if doc else None
1237
+ score = getattr(ls, "_ml_score", getattr(ls, "rank", 0.0)) or 0.0
1238
+
1239
+ # Boost score if content contains important keywords
1240
+ content_text = (ls.content or ls.section_title or "").lower()
1241
+ keyword_boost = 0.0
1242
+ if important_keywords and content_text:
1243
+ for kw in important_keywords:
1244
+ if kw.lower() in content_text:
1245
+ keyword_boost += 0.15 # Boost 0.15 per keyword match
1246
+ logger.debug(
1247
+ "[BOOST] Keyword '%s' found in section %s, boosting score",
1248
+ kw,
1249
+ ls.section_code,
1250
+ )
1251
+
1252
+ entries.append(
1253
+ {
1254
+ "type": "legal",
1255
+ "score": float(score) + keyword_boost,
1256
+ "data": {
1257
+ "id": ls.id,
1258
+ "section_code": ls.section_code,
1259
+ "section_title": ls.section_title,
1260
+ "content": ls.content[:500] if ls.content else "",
1261
+ "excerpt": ls.excerpt,
1262
+ "document_code": doc_code,
1263
+ "document_title": doc.title if doc else None,
1264
+ "page_start": ls.page_start,
1265
+ "page_end": ls.page_end,
1266
+ },
1267
+ }
1268
+ )
1269
+
1270
+ if upper_detected:
1271
+ exact_matches = [
1272
+ r for r in entries if (r["data"].get("document_code") or "").upper() == upper_detected
1273
+ ]
1274
+ if exact_matches:
1275
+ others = [r for r in entries if r not in exact_matches]
1276
+ entries = exact_matches + others
1277
+ else:
1278
+ for entry in entries:
1279
+ doc_code = (entry["data"].get("document_code") or "").upper()
1280
+ if doc_code == upper_detected:
1281
+ entry["score"] = (entry.get("score") or 0.1) * 10
1282
+ entries.sort(key=lambda r: r.get("score") or 0, reverse=True)
1283
+ else:
1284
+ # Sort by boosted score
1285
+ entries.sort(key=lambda r: r.get("score") or 0, reverse=True)
1286
+ return entries
1287
+
1288
+ def _is_complex_query(self, query: str) -> bool:
1289
+ """
1290
+ Detect if query is complex and requires LLM reasoning (not suitable for Fast Path).
1291
+
1292
+ Complex queries contain keywords like: %, bậc, thi đua, tỷ lệ, liên đới, tăng nặng, giảm nhẹ, đơn vị vi phạm
1293
+ """
1294
+ if not query:
1295
+ return False
1296
+ query_lower = query.lower()
1297
+ complex_keywords = [
1298
+ "%", "phần trăm",
1299
+ "bậc", "hạ bậc", "nâng bậc",
1300
+ "thi đua", "xếp loại", "đánh giá",
1301
+ "tỷ lệ", "tỉ lệ",
1302
+ "liên đới", "liên quan",
1303
+ "tăng nặng", "tăng nặng hình phạt",
1304
+ "giảm nhẹ", "giảm nhẹ hình phạt",
1305
+ "đơn vị vi phạm", "đơn vị có",
1306
+ ]
1307
+ for keyword in complex_keywords:
1308
+ if keyword in query_lower:
1309
+ logger.info(
1310
+ "[FAST_PATH] Complex query detected (keyword: '%s'), forcing Slow Path",
1311
+ keyword,
1312
+ )
1313
+ return True
1314
+ return False
1315
+
1316
+ def _maybe_fast_path_response(
1317
+ self, results: List[Dict[str, Any]], query: Optional[str] = None
1318
+ ) -> Optional[Dict[str, Any]]:
1319
+ """Return fast-path response if results are confident enough."""
1320
+ if not results:
1321
+ return None
1322
+
1323
+ # Double-check: if query is complex, never use Fast Path
1324
+ if query and self._is_complex_query(query):
1325
+ return None
1326
+ top_result = results[0]
1327
+ top_score = top_result.get("score", 0.0) or 0.0
1328
+ doc_code = (top_result.get("data", {}).get("document_code") or "").upper()
1329
+
1330
+ if top_score >= 0.88 and doc_code:
1331
+ logger.info(
1332
+ "[FAST_PATH] Top score hit (%.3f) for document %s", top_score, doc_code
1333
+ )
1334
+ message = self._format_fast_legal_message(top_result)
1335
+ return {
1336
+ "message": message,
1337
+ "results": results[:3],
1338
+ "count": min(3, len(results)),
1339
+ "confidence": min(0.99, top_score + 0.05),
1340
+ }
1341
+
1342
+ top_three = results[:3]
1343
+ if len(top_three) >= 2:
1344
+ doc_codes = [
1345
+ (res.get("data", {}).get("document_code") or "").upper()
1346
+ for res in top_three
1347
+ if res.get("data", {}).get("document_code")
1348
+ ]
1349
+ if doc_codes and len(set(doc_codes)) == 1:
1350
+ logger.info(
1351
+ "[FAST_PATH] Top-%d results share same document %s",
1352
+ len(top_three),
1353
+ doc_codes[0],
1354
+ )
1355
+ message = self._format_fast_legal_message(top_three[0])
1356
+ return {
1357
+ "message": message,
1358
+ "results": top_three,
1359
+ "count": len(top_three),
1360
+ "confidence": min(0.97, (top_three[0].get("score") or 0.9) + 0.04),
1361
+ }
1362
+ return None
1363
+
1364
+ def _format_fast_legal_message(self, result: Dict[str, Any]) -> str:
1365
+ """Format a concise legal answer without LLM."""
1366
+ data = result.get("data", {})
1367
+ doc_title = data.get("document_title") or "văn bản pháp luật"
1368
+ doc_code = data.get("document_code") or ""
1369
+ section_code = data.get("section_code") or "Điều liên quan"
1370
+ section_title = data.get("section_title") or ""
1371
+ content = (data.get("content") or data.get("excerpt") or "").strip()
1372
+ if len(content) > 400:
1373
+ trimmed = content[:400].rsplit(" ", 1)[0]
1374
+ content = f"{trimmed}..."
1375
+ intro = "Kết quả chính xác nhất:"
1376
+ lines = [intro]
1377
+ if doc_title or doc_code:
1378
+ lines.append(f"- Văn bản: {doc_title or 'văn bản pháp luật'}" + (f" ({doc_code})" if doc_code else ""))
1379
+ section_label = section_code
1380
+ if section_title:
1381
+ section_label = f"{section_code} – {section_title}"
1382
+ lines.append(f"- Điều khoản: {section_label}")
1383
+ lines.append("")
1384
+ lines.append(content)
1385
+ citation_doc = doc_title or doc_code or "nguồn chính thức"
1386
+ lines.append(f"\nNguồn: {section_label}, {citation_doc}.")
1387
+ return "\n".join(lines)
1388
+