Upload hue_portal/chatbot/chatbot.py with huggingface_hub
Browse files- 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])
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 151 |
-
location="chatbot.py:
|
| 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,
|
| 164 |
if is_follow_up and session_id:
|
| 165 |
-
|
| 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 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 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 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 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 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
"hãy nêu rõ nội dung muốn tìm để tôi trợ giúp nhanh nhất."
|
| 220 |
)
|
|
|
|
|
|
|
| 221 |
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
"
|
| 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 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
if response is None:
|
| 248 |
#region agent log
|
| 249 |
_agent_debug_log(
|
| 250 |
hypothesis_id="H1",
|
| 251 |
-
location="chatbot.py:
|
| 252 |
-
message="
|
| 253 |
data={
|
| 254 |
"is_follow_up": is_follow_up,
|
| 255 |
"session_id_present": bool(session_id),
|
| 256 |
},
|
| 257 |
)
|
| 258 |
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
response = {
|
| 260 |
-
"message":
|
| 261 |
"intent": intent,
|
| 262 |
"confidence": confidence,
|
| 263 |
"results": [],
|
| 264 |
"count": 0,
|
| 265 |
-
|
| 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=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"có đ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 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 "
|
| 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 |
"""
|